Skip to content
Open
Show file tree
Hide file tree
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
5 changes: 4 additions & 1 deletion orpheus/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ def __init__(self, private_mode=False):
"playlist": {
"save_m3u": True,
"paths_m3u": "absolute",
"extended_m3u": True
"extended_m3u": True,
"m3u_only": False,
"sync": False,
"sync_remove_orphaned": False
},
"advanced": {
"advanced_login_system": False,
Expand Down
132 changes: 115 additions & 17 deletions orpheus/music_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1437,10 +1437,23 @@ def get_track_info_wrapper():
args.get('number_of_tracks', 0),
)

# M3U-only mode: skip audio download but record track in playlist file
m3u_only = self.global_settings['playlist'].get('m3u_only', False)
is_playlist_ctx = hasattr(self, 'download_mode') and self.download_mode is DownloadTypeEnum.playlist
if m3u_only and is_playlist_ctx:
track_location = self._create_track_location(args.get('album_location', ''), track_info, extra_kwargs=args.get('extra_kwargs', {}))
m3u_path = args.get('m3u_playlist')
if m3u_path:
await loop.run_in_executor(None, self._add_track_m3u_playlist, m3u_path, track_info, track_location)
return (index, track_name, "SKIPPED", None, None, 0, 0)

# Check if file already exists BEFORE getting download info (for temp file modules like Deezer)
if self._skip_existing_files_enabled() and track_info:
track_location = self._create_track_location(args.get('album_location', ''), track_info, extra_kwargs=args.get('extra_kwargs', {}))
if await loop.run_in_executor(None, os.path.isfile, track_location):
m3u_path = args.get('m3u_playlist')
if m3u_path:
await loop.run_in_executor(None, self._add_track_m3u_playlist, m3u_path, track_info, track_location)
return (index, track_name, "SKIPPED", None, None, 0, 0)

# SINGLE API CALL: Get download info once - IN THREAD POOL
Expand Down Expand Up @@ -1785,7 +1798,11 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
logging.warning(f"Could not retrieve playlist info for {playlist_id} from {self.service_name}. Skipping playlist.")
return []

self.print(f'=== Downloading playlist {playlist_info.name} ({playlist_id}) ===', drop_level=1)
m3u_only_mode = self.global_settings['playlist'].get('m3u_only', False)
if m3u_only_mode:
self.print(f'=== Generating M3U for playlist {playlist_info.name} ({playlist_id}) ===', drop_level=1)
else:
self.print(f'=== Downloading playlist {playlist_info.name} ({playlist_id}) ===', drop_level=1)
self.print(f'Playlist creator: {playlist_info.creator}')
if playlist_info.release_year: self.print(f'Playlist creation year: {playlist_info.release_year}')
if playlist_info.duration: self.print(f'Duration: {beauty_format_seconds(playlist_info.duration)}')
Expand All @@ -1812,6 +1829,48 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
playlist_path += '/'
os.makedirs(playlist_path, exist_ok=True)

# --- Playlist Sync ---
sync_mode = self.global_settings['playlist'].get('sync', False)
sync_remove_orphaned = self.global_settings['playlist'].get('sync_remove_orphaned', False)
sync_manifest_path = os.path.join(playlist_path, '.orpheus_sync.json')
old_manifest = {}
if sync_mode and os.path.isfile(sync_manifest_path):
try:
with open(sync_manifest_path, 'r', encoding='utf-8') as _f:
old_manifest = json.load(_f).get('tracks', {})
except Exception:
old_manifest = {}

def _get_clean_track_id(track_id_or_info):
if isinstance(track_id_or_info, dict):
return str(track_id_or_info.get('id', ''))
if hasattr(track_id_or_info, 'id'):
return str(track_id_or_info.id)
return str(track_id_or_info)

if sync_mode:
current_id_set = {_get_clean_track_id(t) for t in playlist_info.tracks}
removed_ids = set(old_manifest.keys()) - current_id_set
if removed_ids:
print()
self.print(f'{len(removed_ids)} track(s) no longer in playlist:', drop_level=1)
for rid in removed_ids:
rel_path = old_manifest.get(rid)
if rel_path:
full_path = os.path.join(playlist_path, rel_path)
if sync_remove_orphaned:
if os.path.isfile(full_path):
os.remove(full_path)
self.print(f' Deleted: {rel_path}', drop_level=1)
else:
self.print(f' Already gone: {rel_path}', drop_level=1)
else:
status = '(on disk)' if os.path.isfile(full_path) else '(not found on disk)'
self.print(f' {rel_path} {status}', drop_level=1)
else:
self.print(f' Track {rid} removed (path unknown)', drop_level=1)
sync_new_entries = {} # track_id -> relative_path, populated during download loop

self._init_download_error_log(playlist_path, 'playlist', playlist_info.name, playlist_id)
expected_playlist_tracks = playlist_info.num_tracks_from_api or playlist_info.num_tracks
playlist_exclusions = getattr(playlist_info, 'excluded_tracks', None) or []
Expand Down Expand Up @@ -1954,7 +2013,7 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
# Download tracks concurrently
results = self._concurrent_download_tracks(playlist_info.tracks, download_args_list, concurrent_downloads, performance_summary_indent=0)

# Process results - only collect rate-limited tracks for retry
# Process results - collect rate-limited tracks for retry and sync manifest entries
# (Errors are already reported by concurrent download progress monitor)
for index, (original_index, result, error) in enumerate(results):
if error and result == "RATE_LIMITED":
Expand All @@ -1971,6 +2030,15 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
'extra_kwargs': playlist_info.track_extra_kwargs,
'original_index': original_index + 1
})

if sync_mode and original_index < len(playlist_info.tracks):
clean_id = _get_clean_track_id(playlist_info.tracks[original_index])
if isinstance(result, str) and result not in ("SKIPPED", "RATE_LIMITED") and os.path.isfile(result):
sync_new_entries[clean_id] = os.path.relpath(result, playlist_path)
elif result == "SKIPPED" and clean_id in old_manifest:
sync_new_entries[clean_id] = old_manifest[clean_id]
elif result == "SKIPPED":
sync_new_entries[clean_id] = None
else:
# Fallback to sequential downloads
for index, track_id_or_info in enumerate(playlist_info.tracks, start=1):
Expand All @@ -1979,10 +2047,10 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
# Only show "Pass 1" for Spotify (which has retry passes)
pass_indicator = " (Pass 1)" if service_name_lower == 'spotify' else ""
self.print(f'Track {index}/{number_of_tracks}{pass_indicator}', drop_level=1)

# Determine the actual track ID string to use for download_track
actual_track_id_str_for_download = track_id_or_info.id if isinstance(track_id_or_info, TrackInfo) else str(track_id_or_info)

download_result = self.download_track(
actual_track_id_str_for_download,
album_location=playlist_path,
Expand All @@ -1992,28 +2060,33 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
m3u_playlist=m3u_playlist_path,
extra_kwargs=playlist_info.track_extra_kwargs
)


# Update sync manifest entry for this track
if sync_mode:
clean_id = _get_clean_track_id(track_id_or_info)
if isinstance(download_result, str) and download_result not in ("SKIPPED", "RATE_LIMITED") and os.path.isfile(download_result):
sync_new_entries[clean_id] = os.path.relpath(download_result, playlist_path)
elif download_result == "SKIPPED" and clean_id in old_manifest:
sync_new_entries[clean_id] = old_manifest[clean_id]
elif download_result == "SKIPPED":
sync_new_entries[clean_id] = None # file exists but path unknown (first sync)

# Add pause between downloads for Spotify/YouTube to prevent rate limiting
# Only pause if track was actually downloaded (not skipped) and not the last track
if self._handle_spotify_rate_limit_pause(download_result, index, number_of_tracks, service_name_override=service_name_lower):
pass # Pause handled by helper
elif (service_name_lower == 'youtube' and index < number_of_tracks and
elif (service_name_lower == 'youtube' and index < number_of_tracks and
download_result is not None and download_result != "RATE_LIMITED" and download_result != "SKIPPED"):
pause_seconds = self._get_youtube_pause_seconds()
self._sleep_with_countdown(pause_seconds, drop_level=1, with_padding=True)

if download_result == "RATE_LIMITED":
logging.info(f"Deferring track {actual_track_id_str_for_download} due to rate limit.")
rate_limited_tracks.append({
'id': actual_track_id_str_for_download, # Store the string ID
'extra_kwargs': playlist_info.track_extra_kwargs,
'original_index': index
})
elif m3u_playlist_path: # Add to M3U only if download didn't fail/get deferred
# Need to get track_info again or ensure download_track provides location
# This part needs refinement - how to get track_location if download succeeds?
# For now, assume download_track handles its own M3U addition upon success if needed.
pass

# --- Second Pass for Rate-Limited Tracks ---
if rate_limited_tracks:
Expand Down Expand Up @@ -2055,9 +2128,17 @@ def download_playlist(self, playlist_id, custom_module=None, extra_kwargs=None):
self.print("No tracks were deferred due to rate limiting.")
print() # Add blank line after message

# --- Save Sync Manifest ---
if sync_mode:
try:
with open(sync_manifest_path, 'w', encoding='utf-8') as _f:
json.dump({'tracks': sync_new_entries}, _f, indent=2, ensure_ascii=False)
except Exception as _e:
logging.warning(f'Could not save sync manifest: {_e}')

# --- Final Summary ---
self.set_indent_number(1)

symbols = self._get_status_symbols()
self.print(f'=== {symbols["success"]} Playlist completed ===', drop_level=1)
# Add 2 empty lines after playlist completion for visual separation
Expand Down Expand Up @@ -3728,7 +3809,7 @@ def return_with_blank_line(value, failure_reason=None):

# Track info is None - might be OAuth race condition, retry after delay
if attempt < max_retries - 1:
self.print(f'Track info not available, retrying in {retry_delay}s... (attempt {attempt + 1}/{max_retries})')
self.print(f'[Retry {attempt + 1}/{max_retries}] Track info unavailable for {display_track_id}, retrying in {retry_delay}s...')
time.sleep(retry_delay)

except Exception as e:
Expand Down Expand Up @@ -3764,7 +3845,8 @@ def return_with_blank_line(value, failure_reason=None):

# For other exceptions, retry if we have attempts left
if attempt < max_retries - 1:
self.print(f'Error getting track info, retrying in {retry_delay}s... (attempt {attempt + 1}/{max_retries})')
err_short = type(last_exception).__name__
self.print(f'[Retry {attempt + 1}/{max_retries}] Error for {display_track_id} ({err_short}), retrying in {retry_delay}s...')
time.sleep(retry_delay)
else:
service_key = self._service_key()
Expand All @@ -3787,6 +3869,8 @@ def return_with_blank_line(value, failure_reason=None):
return return_with_blank_line(None)

# Check if track_info is still None after all retries
if track_info is not None and attempt > 0:
self.print(f'[Retry succeeded] Got track info for {display_track_id} on attempt {attempt + 1}/{max_retries}')
if track_info is None:
self.print(f'Track info is None for {display_track_id}. Track may be unavailable or not found.')
symbols = self._get_status_symbols()
Expand Down Expand Up @@ -3922,13 +4006,27 @@ def return_with_blank_line(value, failure_reason=None):
self._download_album_files(single_album_path, album_info_for_single)


# M3U-only mode: generate playlist file without downloading audio
m3u_only = self.global_settings['playlist'].get('m3u_only', False)
is_playlist_ctx = hasattr(self, 'download_mode') and self.download_mode is DownloadTypeEnum.playlist
if m3u_only and is_playlist_ctx:
if m3u_playlist:
self._add_track_m3u_playlist(m3u_playlist, track_info, track_location)
if details_indent_adjustment != 0:
self.set_indent_number(indent_level)
symbols = self._get_status_symbols()
d_print(f'=== {symbols["skip"]} M3U only ===', drop_level=header_drop_level)
return return_with_blank_line("SKIPPED")

if self._skip_existing_files_enabled() and os.path.exists(track_location):
d_print(f'Track file already exists')

if m3u_playlist:
self._add_track_m3u_playlist(m3u_playlist, track_info, track_location)

# Restore original indent level if it was adjusted before printing completion message
if details_indent_adjustment != 0:
self.set_indent_number(indent_level)

symbols = self._get_status_symbols()
d_print(f'=== {symbols["skip"]} Track skipped ===', drop_level=header_drop_level)

Expand Down