Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 153 additions & 3 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -10596,6 +10596,92 @@ def delayed_scroll():
_has_shown_unavailable_warning = False
_last_auto_opened_qobuz_auth_url = None

# --- Failed Tracks Panel state ---
_failed_tracks_session = [] # list of (display_name, reason) tuples
_current_download_track_name = None # last "=== Downloading track X" seen in log

def _clear_failed_tracks():
"""Clear the failed tracks list and hide the panel. Call at the start of each new download."""
global _failed_tracks_session, _current_download_track_name
_failed_tracks_session = []
_current_download_track_name = None
_refresh_failed_panel()

def _add_failed_track(name, reason):
global _failed_tracks_session
_failed_tracks_session.append((name, reason))
_refresh_failed_panel()

def _refresh_failed_panel():
"""Update the failed tracks panel widgets to match current _failed_tracks_session."""
try:
frame = globals().get('_failed_panel_frame')
label = globals().get('_failed_panel_label')
textbox = globals().get('_failed_panel_textbox')
if frame is None or not frame.winfo_exists():
return

count = len(_failed_tracks_session)
if count == 0:
frame.grid_remove()
return

# Build content string
lines = []
for name, reason in _failed_tracks_session:
if reason:
lines.append(f"• {name}: {reason}")
else:
lines.append(f"• {name}")
content = '\n'.join(lines)

if label and label.winfo_exists():
label.configure(text=f"FAILED TRACKS ({count})")
if textbox and textbox.winfo_exists():
textbox.configure(state='normal')
textbox.delete('1.0', 'end')
textbox.insert('end', content)
textbox.configure(state='disabled')

frame.grid()
except Exception as _e:
pass

def _detect_failed_track_from_log(raw_line):
"""Parse a log line and update the failed tracks panel if it indicates a failure."""
global _current_download_track_name
try:
# Strip color/platform markers
plain = re.sub(r'\|(?:RED|YELLOW|GRAY|GREEN|WHITE|RESET|PLATFORM_\w+)\|', '', raw_line).strip()

# Track current track name from sequential download header
m = re.search(r'=== Downloading track (.+?) \([\w\-]+\) ===', plain)
if m:
_current_download_track_name = m.group(1).strip()
return

# Seed track ID from retry messages when "=== Downloading track" hasn't appeared yet
# e.g. "[Retry 1/3] Error for 12345 ..." or "[Retry 1] Decryption ... for track 12345."
if not _current_download_track_name:
m = re.search(r'\[Retry \d+(?:/\d+)?\].+?(?:for (?:track )?)([\w\-]+)', plain)
if m:
_current_download_track_name = m.group(1).strip()
return

# Sequential failure banner: "=== ✗ Track failed ===" (symbols['error'] = '✗')
if re.search(r'=== .{0,5} Track failed ===', plain):
name = _current_download_track_name or 'Unknown track'
_add_failed_track(name, '')
_current_download_track_name = None
return

# Concurrent failure: "03/10 ❌ Track Name: Error reason (failed)"
m = re.search(r'❌\s+(.+?)(?::\s+(.+?))?\s*\(failed\)\s*$', plain)
if m:
_add_failed_track(m.group(1).strip(), (m.group(2) or '').strip())
except Exception:
pass

def _maybe_auto_open_qobuz_auth_url(msg):
"""Auto-open Qobuz OAuth URL once per unique URL during GUI sessions."""
global _last_auto_opened_qobuz_auth_url
Expand Down Expand Up @@ -10770,6 +10856,14 @@ def update_log_area():
if 'Processing' in msg_strip and 'standalone tracks...' in msg_strip and 'Artist Progress:' in msg_strip:
log_to_textbox(f"{msg}\n")
continue
if msg_strip.startswith('[Retry succeeded]'):
log_to_textbox(f"|GREEN| {msg_strip}|RESET|\n")
continue
if msg_strip.startswith('[Retry ') or (
msg_strip.startswith('[Retry') and ']' in msg_strip
):
log_to_textbox(f"|YELLOW| {msg_strip}|RESET|\n")
continue
if 'Spotify authentication error during track download' in msg_strip or \
('Download attempt' in msg_strip and 'failed. Retrying in' in msg_strip):
cleaned_message = msg_strip
Expand All @@ -10793,12 +10887,13 @@ def update_log_area():
pass
continue
is_error = msg_strip.startswith(('[WARNING]', '[ERROR]'))
if is_error and (('Download attempt' in msg_strip and ('failed' in msg_strip or 'Retrying' in msg_strip)) or
if is_error and (('Download attempt' in msg_strip and ('failed' in msg_strip or 'Retrying' in msg_strip)) or
'Failed after 3 attempts' in msg_strip):
log_to_textbox(f" {msg_strip}\n", error=is_error)
else:
log_to_textbox(msg, error=is_error)

_detect_failed_track_from_log(msg_strip)
messages_processed += 1
if time.time() - start_time > max_processing_time:
break
Expand Down Expand Up @@ -12991,7 +13086,8 @@ def start_download_thread(search_result_data=None):
input_text = url_entry.get().strip()
if not input_text:
show_centered_messagebox("Info", "Please enter a URL or a file path.", dialog_type="warning"); return
# New user-initiated download session: show Spotify suspension warning once.
# New user-initiated download session: reset failed tracks panel and Spotify warning flag.
_clear_failed_tracks()
spotify_pre_download_warning_acknowledged = False

output_path = path_var_main.get().strip()
Expand Down Expand Up @@ -19470,7 +19566,7 @@ def _auto_save_credential_change(platform_name, key, widget=None, *args):
"module_defaults": { "lyrics": "default", "covers": "default", "credits": "default" },
"lyrics": { "embed_lyrics": True, "embed_synced_lyrics": False, "save_synced_lyrics": True },
"covers": { "embed_cover": True, "main_compression": "high", "main_resolution": 1400, "save_original_cover_size": False, "save_external": False, "external_format": "png", "external_compression": "low", "external_resolution": 3000, "save_animated_cover": True },
"playlist": { "save_m3u": True, "paths_m3u": "absolute", "extended_m3u": True },
"playlist": { "save_m3u": True, "paths_m3u": "absolute", "extended_m3u": True, "m3u_only": False, "sync": False, "sync_remove_orphaned": False },
"advanced": {
"advanced_login_system": False,
"codec_conversions": { "alac": "flac", "wav": "flac", "vorbis": "vorbis" },
Expand Down Expand Up @@ -19957,6 +20053,57 @@ def _on_log_mousewheel(event):
log_textbox.bind("<MouseWheel>", _on_log_mousewheel) # Windows/macOS
log_textbox.bind("<Button-4>", lambda e: _on_log_mousewheel(type('Event', (), {'delta': 120})())) # Linux scroll up
log_textbox.bind("<Button-5>", lambda e: _on_log_mousewheel(type('Event', (), {'delta': -120})())) # Linux scroll down

# --- Failed Tracks Panel ---
# Sits inside output_frame as row 2 (below the log); hidden until failures occur.
output_frame.grid_rowconfigure(2, weight=0)
_failed_panel_frame = customtkinter.CTkFrame(output_frame, fg_color=CONTAINER_COLOR, corner_radius=6)
_failed_panel_frame.grid(row=2, column=0, sticky="ew", pady=(6, 0))
_failed_panel_frame.grid_columnconfigure(1, weight=1)
_failed_panel_frame.grid_remove() # hidden until there are failures
globals()['_failed_panel_frame'] = _failed_panel_frame

_fp_header = customtkinter.CTkFrame(_failed_panel_frame, fg_color="transparent")
_fp_header.grid(row=0, column=0, columnspan=3, sticky="ew", padx=6, pady=(4, 0))
_fp_header.grid_columnconfigure(1, weight=1)

_failed_panel_label = customtkinter.CTkLabel(
_fp_header, text="FAILED TRACKS",
text_color=ERROR_COLOR, font=("Segoe UI", 10, "bold"), anchor="w"
)
_failed_panel_label.grid(row=0, column=0, sticky="w")
globals()['_failed_panel_label'] = _failed_panel_label

def _copy_failed_tracks():
try:
lines = [f"{n}: {r}" if r else n for n, r in _failed_tracks_session]
app.clipboard_clear(); app.clipboard_append('\n'.join(lines))
except Exception: pass

def _dismiss_failed_panel():
try:
_failed_panel_frame.grid_remove()
except Exception: pass

customtkinter.CTkButton(
_fp_header, text="Copy", width=60, height=22,
fg_color=UI_ELEMENT_BG_COLOR, hover_color=LINK_COLOR,
command=_copy_failed_tracks
).grid(row=0, column=2, sticky="e", padx=(4, 2))
customtkinter.CTkButton(
_fp_header, text="X", width=28, height=22,
fg_color=UI_ELEMENT_BG_COLOR, hover_color=LINK_COLOR,
command=_dismiss_failed_panel
).grid(row=0, column=3, sticky="e", padx=(0, 4))

_failed_panel_textbox = tkinter.Text(
_failed_panel_frame, height=3, wrap=tkinter.WORD, state='disabled',
font=log_font, bg=CONTAINER_COLOR, fg=ERROR_COLOR,
relief="flat", borderwidth=0, highlightthickness=0, exportselection=False
)
_failed_panel_textbox.grid(row=1, column=0, columnspan=4, sticky="ew", padx=8, pady=(2, 6))
globals()['_failed_panel_textbox'] = _failed_panel_textbox

bottom_frame = customtkinter.CTkFrame(download_tab, fg_color="transparent"); bottom_frame.grid(row=3, column=0, columnspan=4, sticky="ew", padx=10, pady=(5, 10)); bottom_frame.grid_columnconfigure(0, weight=1)
progress_bar = customtkinter.CTkProgressBar(bottom_frame, indeterminate_speed=0.3); progress_bar.set(0); progress_bar.grid(row=0, column=0, sticky="ew", padx=(5, 5))
clear_output_button = customtkinter.CTkButton(bottom_frame, text="Clear Output", width=100, height=30, command=clear_output_log, fg_color=UI_ELEMENT_BG_COLOR, hover_color=LINK_COLOR); clear_output_button.grid(row=0, column=1, sticky="e", padx=(5, 10))
Expand Down Expand Up @@ -20474,6 +20621,9 @@ def _global_settings_mousewheel(event):
"playlist.save_m3u": "Create an M3U playlist file for playlist downloads.",
"playlist.paths_m3u": "Select 'relative' or 'absolute' paths in M3U file.",
"playlist.extended_m3u": "Include extended info like track length in M3U file.",
"playlist.m3u_only": "Skip audio downloads and only generate the M3U playlist file.\nUseful for re-creating playlist files for tracks you already have on disk.",
"playlist.sync": "Enable playlist sync mode.\nWhen re-downloading a playlist, tracks already on disk are skipped and only new additions are downloaded.\nRemoved tracks are reported (and optionally deleted if sync_remove_orphaned is enabled).\nA hidden .orpheus_sync.json manifest is saved in the playlist folder to track changes.",
"playlist.sync_remove_orphaned": "When sync mode is enabled, automatically delete files for tracks that have been removed from the playlist.\nHas no effect unless playlist.sync is also enabled.",
"advanced.advanced_login_system": "Enable advanced login system (Use only if instructed by module documentation).",
"advanced.ffmpeg_path": "Full path to the ffmpeg executable \nIf set to just 'ffmpeg', it's assumed to be in the system PATH.\nThis is used for codec conversions.",
"advanced.codec_conversions": "Defines automatic codec remapping before save (for example: alac -> flac).\nLeft dropdown = source codec from module, right dropdown = target codec to convert to.",
Expand Down