Fix m3u file naming handling
This commit is contained in:
@@ -266,7 +266,10 @@ class EASY_DW:
|
||||
self.__track_obj: trackCbObject = preferences.song_metadata
|
||||
|
||||
# Convert it to the dictionary format needed for legacy functions
|
||||
self.__song_metadata = self._track_object_to_dict(self.__track_obj)
|
||||
artist_separator = getattr(preferences, 'artist_separator', '; ')
|
||||
self.__song_metadata_dict = track_object_to_dict(self.__track_obj, source_type='deezer', artist_separator=artist_separator)
|
||||
# Maintain legacy attribute expected elsewhere
|
||||
self.__song_metadata = self.__song_metadata_dict
|
||||
self.__download_type = "track"
|
||||
|
||||
self.__c_quality = qualities[self.__quality_download]
|
||||
@@ -324,7 +327,8 @@ class EASY_DW:
|
||||
It intelligently finds the album information based on the download context.
|
||||
"""
|
||||
# Use the unified metadata converter
|
||||
metadata_dict = track_object_to_dict(track_obj, source_type='deezer')
|
||||
artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
metadata_dict = track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
|
||||
|
||||
# Check for track_position and disk_number in the original API data
|
||||
# These might be directly available in the infos_dw dictionary for Deezer tracks
|
||||
@@ -345,8 +349,9 @@ class EASY_DW:
|
||||
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
|
||||
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
|
||||
pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
|
||||
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
self.__song_path = set_path(
|
||||
self.__song_metadata,
|
||||
self.__song_metadata_dict,
|
||||
self.__output_dir,
|
||||
self.__song_quality,
|
||||
self.__file_format,
|
||||
@@ -360,7 +365,7 @@ class EASY_DW:
|
||||
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
|
||||
pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
|
||||
self.__song_path = set_path(
|
||||
self.__song_metadata,
|
||||
self.__song_metadata_dict,
|
||||
self.__output_dir,
|
||||
self.__song_quality,
|
||||
self.__file_format,
|
||||
@@ -953,7 +958,8 @@ class DW_ALBUM:
|
||||
def _album_object_to_dict(self, album_obj: albumCbObject) -> dict:
|
||||
"""Converts an albumObject to a dictionary for tagging and path generation."""
|
||||
# Use the unified metadata converter
|
||||
return album_object_to_dict(album_obj, source_type='deezer')
|
||||
artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
return album_object_to_dict(album_obj, source_type='deezer', artist_separator=artist_separator)
|
||||
|
||||
def _track_object_to_dict(self, track_obj: any, album_obj: albumCbObject) -> dict:
|
||||
"""Converts a track object to a dictionary with album context."""
|
||||
@@ -973,10 +979,12 @@ class DW_ALBUM:
|
||||
genres=getattr(track_obj, 'genres', [])
|
||||
)
|
||||
# Use the unified metadata converter
|
||||
return track_object_to_dict(full_track, source_type='deezer')
|
||||
artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
return track_object_to_dict(full_track, source_type='deezer', artist_separator=artist_separator)
|
||||
else:
|
||||
# Use the unified metadata converter
|
||||
return track_object_to_dict(track_obj, source_type='deezer')
|
||||
artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
return track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -992,6 +1000,7 @@ class DW_ALBUM:
|
||||
self.__recursive_quality = self.__preferences.recursive_quality
|
||||
album_obj: albumCbObject = self.__preferences.song_metadata
|
||||
self.__song_metadata = self._album_object_to_dict(album_obj)
|
||||
self.__song_metadata['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
|
||||
def dw(self) -> Album:
|
||||
from deezspot.deezloader.deegw_api import API_GW
|
||||
@@ -1139,7 +1148,8 @@ class DW_PLAYLIST:
|
||||
|
||||
def _track_object_to_dict(self, track_obj: any) -> dict:
|
||||
# Use the unified metadata converter
|
||||
return track_object_to_dict(track_obj, source_type='deezer')
|
||||
artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
return track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
|
||||
|
||||
def dw(self) -> Playlist:
|
||||
playlist_obj: playlistCbObject = self.__preferences.json_data
|
||||
|
||||
@@ -125,7 +125,8 @@ class DeeLogin:
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market,
|
||||
playlist_context=None
|
||||
playlist_context=None,
|
||||
artist_separator: str = "; "
|
||||
) -> Track:
|
||||
|
||||
link_is_valid(link_track)
|
||||
@@ -192,6 +193,7 @@ class DeeLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
if playlist_context:
|
||||
preferences.json_data = playlist_context['json_data']
|
||||
@@ -226,7 +228,8 @@ class DeeLogin:
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market,
|
||||
playlist_context=None
|
||||
playlist_context=None,
|
||||
artist_separator: str = "; "
|
||||
) -> Album:
|
||||
|
||||
link_is_valid(link_album)
|
||||
@@ -273,6 +276,7 @@ class DeeLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
if playlist_context:
|
||||
preferences.json_data = playlist_context['json_data']
|
||||
@@ -305,7 +309,8 @@ class DeeLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market
|
||||
market=stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Playlist:
|
||||
|
||||
link_is_valid(link_playlist)
|
||||
@@ -339,6 +344,7 @@ class DeeLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
playlist = DW_PLAYLIST(preferences).dw()
|
||||
|
||||
@@ -591,7 +597,8 @@ class DeeLogin:
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market,
|
||||
playlist_context=None
|
||||
playlist_context=None,
|
||||
artist_separator: str = "; "
|
||||
) -> Track:
|
||||
|
||||
link_dee = self.convert_spoty_to_dee_link_track(link_track)
|
||||
@@ -613,7 +620,8 @@ class DeeLogin:
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market,
|
||||
playlist_context=playlist_context
|
||||
playlist_context=playlist_context,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
|
||||
return track
|
||||
@@ -636,7 +644,8 @@ class DeeLogin:
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market,
|
||||
playlist_context=None
|
||||
playlist_context=None,
|
||||
artist_separator: str = "; "
|
||||
) -> Album:
|
||||
|
||||
link_dee = self.convert_spoty_to_dee_link_album(link_album)
|
||||
@@ -656,7 +665,8 @@ class DeeLogin:
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market,
|
||||
playlist_context=playlist_context
|
||||
playlist_context=playlist_context,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
|
||||
return album
|
||||
@@ -678,7 +688,8 @@ class DeeLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market
|
||||
market=stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Playlist:
|
||||
|
||||
link_is_valid(link_playlist)
|
||||
@@ -821,7 +832,8 @@ class DeeLogin:
|
||||
custom_track_format=custom_track_format, pad_tracks=pad_tracks,
|
||||
initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase,
|
||||
max_retries=max_retries, convert_to=convert_to, bitrate=bitrate,
|
||||
save_cover=save_cover, market=market, playlist_context=playlist_context
|
||||
save_cover=save_cover, market=market, playlist_context=playlist_context,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
tracks.append(downloaded_track)
|
||||
|
||||
@@ -898,7 +910,8 @@ class DeeLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market
|
||||
market=stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Track:
|
||||
|
||||
query = f"track:{song} artist:{artist}"
|
||||
@@ -928,7 +941,8 @@ class DeeLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market
|
||||
market=market,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
|
||||
return track
|
||||
@@ -950,7 +964,8 @@ class DeeLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market
|
||||
market=stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Episode:
|
||||
|
||||
logger.warning("Episode download logic is not fully refactored and might not work as expected with new reporting.")
|
||||
@@ -990,6 +1005,7 @@ class DeeLogin:
|
||||
preferences.save_cover = save_cover
|
||||
preferences.is_episode = True
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
episode = DW_EPISODE(preferences).dw()
|
||||
|
||||
@@ -1012,7 +1028,8 @@ class DeeLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market=stock_market
|
||||
market=stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Smart:
|
||||
|
||||
link_is_valid(link)
|
||||
@@ -1040,7 +1057,7 @@ class DeeLogin:
|
||||
custom_track_format=custom_track_format, pad_tracks=pad_tracks,
|
||||
initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase,
|
||||
max_retries=max_retries, convert_to=convert_to, bitrate=bitrate,
|
||||
save_cover=save_cover, market=market
|
||||
save_cover=save_cover, market=market, artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "track"
|
||||
smart.track = track
|
||||
@@ -1055,7 +1072,7 @@ class DeeLogin:
|
||||
pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay,
|
||||
retry_delay_increase=retry_delay_increase, max_retries=max_retries,
|
||||
convert_to=convert_to, bitrate=bitrate, save_cover=save_cover,
|
||||
market=market
|
||||
market=market, artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "album"
|
||||
smart.album = album
|
||||
@@ -1070,7 +1087,7 @@ class DeeLogin:
|
||||
pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay,
|
||||
retry_delay_increase=retry_delay_increase, max_retries=max_retries,
|
||||
convert_to=convert_to, bitrate=bitrate, save_cover=save_cover,
|
||||
market=market
|
||||
market=market, artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "playlist"
|
||||
smart.playlist = playlist
|
||||
|
||||
@@ -108,7 +108,7 @@ def _get_best_image_url(images: Any, source_type: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
|
||||
def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None, artist_separator: str = "; ") -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a track object to a dictionary format for tagging.
|
||||
Supports both Spotify and Deezer track objects.
|
||||
@@ -116,6 +116,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
|
||||
Args:
|
||||
track_obj: Track object from Spotify or Deezer API
|
||||
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
|
||||
artist_separator: Separator string for joining multiple artists
|
||||
|
||||
Returns:
|
||||
Dictionary with standardized metadata tags
|
||||
@@ -142,7 +143,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
|
||||
|
||||
# Artist information
|
||||
if hasattr(track_obj, 'artists') and track_obj.artists:
|
||||
tags['artist'] = "; ".join([getattr(artist, 'name', '') for artist in track_obj.artists])
|
||||
tags['artist'] = artist_separator.join([getattr(artist, 'name', '') for artist in track_obj.artists])
|
||||
else:
|
||||
tags['artist'] = ''
|
||||
|
||||
@@ -157,7 +158,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
|
||||
|
||||
# Album artists
|
||||
if hasattr(album, 'artists') and album.artists:
|
||||
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album.artists])
|
||||
tags['ar_album'] = artist_separator.join([getattr(artist, 'name', '') for artist in album.artists])
|
||||
else:
|
||||
tags['ar_album'] = ''
|
||||
|
||||
@@ -210,7 +211,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
|
||||
return tags
|
||||
|
||||
|
||||
def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
|
||||
def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None, artist_separator: str = "; ") -> Dict[str, Any]:
|
||||
"""
|
||||
Convert an album object to a dictionary format for tagging.
|
||||
Supports both Spotify and Deezer album objects.
|
||||
@@ -218,6 +219,7 @@ def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> D
|
||||
Args:
|
||||
album_obj: Album object from Spotify or Deezer API
|
||||
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
|
||||
artist_separator: Separator string for joining multiple album artists
|
||||
|
||||
Returns:
|
||||
Dictionary with standardized metadata tags
|
||||
@@ -236,7 +238,7 @@ def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> D
|
||||
|
||||
# Album artists
|
||||
if hasattr(album_obj, 'artists') and album_obj.artists:
|
||||
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album_obj.artists])
|
||||
tags['ar_album'] = artist_separator.join([getattr(artist, 'name', '') for artist in album_obj.artists])
|
||||
else:
|
||||
tags['ar_album'] = ''
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import re
|
||||
from unicodedata import normalize
|
||||
from os import makedirs
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
@@ -43,6 +44,11 @@ def __check_dir(directory):
|
||||
def sanitize_name(string, max_length=200):
|
||||
"""Sanitize a string for use as a filename or directory name.
|
||||
|
||||
This version maps filesystem-conflicting ASCII characters to Unicode
|
||||
lookalikes (mostly fullwidth forms) rather than dropping or replacing
|
||||
with ASCII fallbacks. This preserves readability while avoiding
|
||||
path-separator or Windows-invalid characters.
|
||||
|
||||
Args:
|
||||
string: The string to sanitize
|
||||
max_length: Maximum length for the resulting string
|
||||
@@ -56,20 +62,22 @@ def sanitize_name(string, max_length=200):
|
||||
# Convert to string if not already
|
||||
string = str(string)
|
||||
|
||||
# Enhance character replacement for filenames
|
||||
# Map invalid/reserved characters to Unicode fullwidth or similar lookalikes
|
||||
# to avoid filesystem conflicts while keeping readability.
|
||||
# Windows-invalid: < > : " / \ | ? * and control chars
|
||||
replacements = {
|
||||
"\\": "-", # Backslash to hyphen
|
||||
"/": "-", # Forward slash to hyphen
|
||||
":": "-", # Colon to hyphen
|
||||
"*": "+", # Asterisk to plus
|
||||
"?": "", # Question mark removed
|
||||
"\"": "'", # Double quote to single quote
|
||||
"<": "[", # Less than to open bracket
|
||||
">": "]", # Greater than to close bracket
|
||||
"|": "-", # Pipe to hyphen
|
||||
"&": "and", # Ampersand to 'and'
|
||||
"$": "s", # Dollar to 's'
|
||||
";": ",", # Semicolon to comma
|
||||
"\\": "\", # U+FF3C FULLWIDTH REVERSE SOLIDUS
|
||||
"/": "/", # U+FF0F FULLWIDTH SOLIDUS
|
||||
":": ":", # U+FF1A FULLWIDTH COLON
|
||||
"*": "*", # U+FF0A FULLWIDTH ASTERISK
|
||||
"?": "?", # U+FF1F FULLWIDTH QUESTION MARK
|
||||
"\"": """, # U+FF02 FULLWIDTH QUOTATION MARK
|
||||
"<": "<", # U+FF1C FULLWIDTH LESS-THAN SIGN
|
||||
">": ">", # U+FF1E FULLWIDTH GREATER-THAN SIGN
|
||||
"|": "|", # U+FF5C FULLWIDTH VERTICAL LINE
|
||||
"&": "&", # U+FF06 FULLWIDTH AMPERSAND
|
||||
"$": "$", # U+FF04 FULLWIDTH DOLLAR SIGN
|
||||
";": ";", # U+FF1B FULLWIDTH SEMICOLON
|
||||
"\t": " ", # Tab to space
|
||||
"\n": " ", # Newline to space
|
||||
"\r": " ", # Carriage return to space
|
||||
@@ -99,6 +107,10 @@ def sanitize_name(string, max_length=200):
|
||||
if not string:
|
||||
string = "Unknown"
|
||||
|
||||
# Normalize to NFC to keep composed characters stable but avoid
|
||||
# compatibility decomposition that might revert fullwidth mappings.
|
||||
string = normalize('NFC', string)
|
||||
|
||||
return string
|
||||
|
||||
# Keep the original function name for backward compatibility
|
||||
@@ -127,6 +139,11 @@ def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str:
|
||||
def replacer(match):
|
||||
full_key = match.group(1) # e.g., "artist", "ar_album_1"
|
||||
|
||||
# Allow custom artist/album-artist separator to be provided via metadata
|
||||
separator = metadata.get('artist_separator', ';')
|
||||
if not isinstance(separator, str) or separator == "":
|
||||
separator = ';'
|
||||
|
||||
# Check for specific indexed placeholders: artist_INDEX or ar_album_INDEX
|
||||
# Allows %artist_1%, %ar_album_1%, etc.
|
||||
indexed_artist_match = re.fullmatch(r'(artist|ar_album)_(\d+)', full_key)
|
||||
@@ -143,8 +160,8 @@ def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str:
|
||||
items = []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
# Split semicolon-separated strings and strip whitespace
|
||||
items = [item.strip() for item in raw_value.split(';') if item.strip()]
|
||||
# Split by provided separator and strip whitespace
|
||||
items = [item.strip() for item in raw_value.split(separator) if item.strip()]
|
||||
elif isinstance(raw_value, list):
|
||||
# Convert all items to string, strip whitespace
|
||||
items = [str(item).strip() for item in raw_value if str(item).strip()]
|
||||
@@ -193,13 +210,14 @@ def __get_dir(song_metadata, output_dir, custom_dir_format=None, pad_tracks=True
|
||||
__check_dir(output_dir)
|
||||
return output_dir
|
||||
|
||||
# Apply the custom format string.
|
||||
# pad_tracks is passed along in case 'tracknum' or 'discnum' are used in dir format.
|
||||
formatted_path_segment = apply_custom_format(custom_dir_format, song_metadata, pad_tracks)
|
||||
|
||||
# Sanitize each component of the formatted path segment
|
||||
# Apply formatting per path component so only slashes from the format
|
||||
# create directories; slashes from data are sanitized inside components.
|
||||
format_parts = custom_dir_format.split("/")
|
||||
formatted_parts = [
|
||||
apply_custom_format(part, song_metadata, pad_tracks) for part in format_parts
|
||||
]
|
||||
sanitized_path_segment = "/".join(
|
||||
sanitize_name(part) for part in formatted_path_segment.split("/")
|
||||
sanitize_name(part) for part in formatted_parts
|
||||
)
|
||||
|
||||
# Join with the base output directory
|
||||
|
||||
@@ -10,74 +10,74 @@ from deezspot.models.download import Track
|
||||
def create_m3u_file(output_dir: str, playlist_name: str) -> str:
|
||||
"""
|
||||
Creates an m3u playlist file with the proper header.
|
||||
|
||||
Args:
|
||||
output_dir: Base output directory
|
||||
playlist_name: Name of the playlist (will be sanitized)
|
||||
|
||||
Returns:
|
||||
str: Full path to the created m3u file
|
||||
Returns full path to the m3u file.
|
||||
"""
|
||||
playlist_m3u_dir = os.path.join(output_dir, "playlists")
|
||||
os.makedirs(playlist_m3u_dir, exist_ok=True)
|
||||
|
||||
playlist_name_sanitized = sanitize_name(playlist_name)
|
||||
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
|
||||
|
||||
# Always ensure header exists (idempotent)
|
||||
if not os.path.exists(m3u_path):
|
||||
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
|
||||
m3u_file.write("#EXTM3U\n")
|
||||
logger.debug(f"Created m3u playlist file: {m3u_path}")
|
||||
|
||||
return m3u_path
|
||||
|
||||
|
||||
def ensure_m3u_header(m3u_path: str) -> None:
|
||||
"""Ensure an existing m3u has the header; create if missing."""
|
||||
if not os.path.exists(m3u_path):
|
||||
os.makedirs(os.path.dirname(m3u_path), exist_ok=True)
|
||||
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
|
||||
m3u_file.write("#EXTM3U\n")
|
||||
|
||||
|
||||
# Prefer the actual file that exists on disk; if the stored path doesn't exist,
|
||||
# attempt to find the same basename with a different extension (e.g., due to conversion).
|
||||
_AUDIO_EXTS_TRY = [
|
||||
".flac", ".mp3", ".m4a", ".aac", ".alac", ".ogg", ".opus", ".wav", ".aiff"
|
||||
]
|
||||
|
||||
|
||||
def _resolve_existing_song_path(song_path: str) -> Union[str, None]:
|
||||
if not song_path:
|
||||
return None
|
||||
if os.path.exists(song_path):
|
||||
return song_path
|
||||
base, _ = os.path.splitext(song_path)
|
||||
for ext in _AUDIO_EXTS_TRY:
|
||||
candidate = base + ext
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _get_track_duration_seconds(track: Track) -> int:
|
||||
"""
|
||||
Extract track duration in seconds from track metadata.
|
||||
|
||||
Args:
|
||||
track: Track object
|
||||
|
||||
Returns:
|
||||
int: Duration in seconds, defaults to 0 if not available
|
||||
"""
|
||||
try:
|
||||
# Try to get duration from tags first
|
||||
if hasattr(track, 'tags') and track.tags:
|
||||
if 'duration' in track.tags:
|
||||
return int(float(track.tags['duration']))
|
||||
elif 'length' in track.tags:
|
||||
return int(float(track.tags['length']))
|
||||
|
||||
# Try to get from song_metadata if available
|
||||
if hasattr(track, 'song_metadata') and hasattr(track.song_metadata, 'duration_ms'):
|
||||
return int(track.song_metadata.duration_ms / 1000)
|
||||
|
||||
# Fallback to 0 if no duration found
|
||||
return 0
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def _get_track_info(track: Track) -> tuple:
|
||||
"""
|
||||
Extract artist and title information from track.
|
||||
|
||||
Args:
|
||||
track: Track object
|
||||
|
||||
Returns:
|
||||
tuple: (artist, title) strings
|
||||
"""
|
||||
try:
|
||||
if hasattr(track, 'tags') and track.tags:
|
||||
artist = track.tags.get('artist', 'Unknown Artist')
|
||||
title = track.tags.get('music', track.tags.get('title', 'Unknown Title'))
|
||||
return artist, title
|
||||
elif hasattr(track, 'song_metadata'):
|
||||
sep = ", "
|
||||
if hasattr(track, 'tags') and track.tags:
|
||||
sep = track.tags.get('artist_separator', sep)
|
||||
if hasattr(track.song_metadata, 'artists') and track.song_metadata.artists:
|
||||
artist = ', '.join([a.name for a in track.song_metadata.artists])
|
||||
artist = sep.join([a.name for a in track.song_metadata.artists])
|
||||
else:
|
||||
artist = 'Unknown Artist'
|
||||
title = getattr(track.song_metadata, 'title', 'Unknown Title')
|
||||
@@ -89,40 +89,28 @@ def _get_track_info(track: Track) -> tuple:
|
||||
|
||||
|
||||
def append_track_to_m3u(m3u_path: str, track: Union[str, Track]) -> None:
|
||||
"""
|
||||
Appends a single track to an existing m3u file with extended format.
|
||||
|
||||
Args:
|
||||
m3u_path: Full path to the m3u file
|
||||
track: Track object or string path to track file
|
||||
"""
|
||||
"""Append a single track to m3u with EXTINF and a resolved path."""
|
||||
ensure_m3u_header(m3u_path)
|
||||
if isinstance(track, str):
|
||||
# Legacy support for string paths
|
||||
track_path = track
|
||||
if not track_path or not os.path.exists(track_path):
|
||||
resolved = _resolve_existing_song_path(track)
|
||||
if not resolved:
|
||||
return
|
||||
|
||||
playlist_m3u_dir = os.path.dirname(m3u_path)
|
||||
relative_path = os.path.relpath(track_path, start=playlist_m3u_dir)
|
||||
|
||||
relative_path = os.path.relpath(resolved, start=playlist_m3u_dir)
|
||||
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
|
||||
m3u_file.write(f"{relative_path}\n")
|
||||
else:
|
||||
# Track object with full metadata
|
||||
if (not isinstance(track, Track) or
|
||||
not track.success or
|
||||
not hasattr(track, 'song_path') or
|
||||
not track.song_path or
|
||||
not os.path.exists(track.song_path)):
|
||||
not hasattr(track, 'song_path')):
|
||||
return
|
||||
resolved = _resolve_existing_song_path(track.song_path)
|
||||
if not resolved:
|
||||
return
|
||||
|
||||
playlist_m3u_dir = os.path.dirname(m3u_path)
|
||||
relative_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
|
||||
|
||||
# Get track metadata
|
||||
relative_path = os.path.relpath(resolved, start=playlist_m3u_dir)
|
||||
duration = _get_track_duration_seconds(track)
|
||||
artist, title = _get_track_info(track)
|
||||
|
||||
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
|
||||
m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\n")
|
||||
m3u_file.write(f"{relative_path}\n")
|
||||
@@ -130,57 +118,19 @@ def append_track_to_m3u(m3u_path: str, track: Union[str, Track]) -> None:
|
||||
|
||||
def write_tracks_to_m3u(output_dir: str, playlist_name: str, tracks: List[Track]) -> str:
|
||||
"""
|
||||
Creates an m3u file and writes all successful tracks to it at once using extended format.
|
||||
|
||||
Args:
|
||||
output_dir: Base output directory
|
||||
playlist_name: Name of the playlist (will be sanitized)
|
||||
tracks: List of Track objects
|
||||
|
||||
Returns:
|
||||
str: Full path to the created m3u file
|
||||
Legacy batch method. Creates an m3u and writes provided tracks.
|
||||
Prefer progressive usage: create_m3u_file(...) once, then append_track_to_m3u(...) per track.
|
||||
"""
|
||||
playlist_m3u_dir = os.path.join(output_dir, "playlists")
|
||||
os.makedirs(playlist_m3u_dir, exist_ok=True)
|
||||
|
||||
playlist_name_sanitized = sanitize_name(playlist_name)
|
||||
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
|
||||
|
||||
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
|
||||
m3u_file.write("#EXTM3U\n")
|
||||
|
||||
m3u_path = os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")
|
||||
ensure_m3u_header(m3u_path)
|
||||
for track in tracks:
|
||||
if (isinstance(track, Track) and
|
||||
track.success and
|
||||
hasattr(track, 'song_path') and
|
||||
track.song_path and
|
||||
os.path.exists(track.song_path)):
|
||||
|
||||
relative_song_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
|
||||
|
||||
# Get track metadata
|
||||
duration = _get_track_duration_seconds(track)
|
||||
artist, title = _get_track_info(track)
|
||||
|
||||
# Write EXTINF line with duration and metadata
|
||||
m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\n")
|
||||
m3u_file.write(f"{relative_song_path}\n")
|
||||
|
||||
append_track_to_m3u(m3u_path, track)
|
||||
logger.info(f"Created m3u playlist file at: {m3u_path}")
|
||||
return m3u_path
|
||||
|
||||
|
||||
def get_m3u_path(output_dir: str, playlist_name: str) -> str:
|
||||
"""
|
||||
Get the expected path for an m3u file without creating it.
|
||||
|
||||
Args:
|
||||
output_dir: Base output directory
|
||||
playlist_name: Name of the playlist (will be sanitized)
|
||||
|
||||
Returns:
|
||||
str: Full path where the m3u file would be located
|
||||
"""
|
||||
playlist_m3u_dir = os.path.join(output_dir, "playlists")
|
||||
playlist_name_sanitized = sanitize_name(playlist_name)
|
||||
return os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
|
||||
return os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")
|
||||
@@ -13,11 +13,13 @@ class Preferences:
|
||||
self.recursive_download = None
|
||||
self.not_interface = None
|
||||
self.make_zip = None
|
||||
self.real_time_dl = None ,
|
||||
self.custom_dir_format = None,
|
||||
self.custom_track_format = None,
|
||||
self.real_time_dl = None
|
||||
self.custom_dir_format = None
|
||||
self.custom_track_format = None
|
||||
self.pad_tracks = True # Default to padded track numbers (01, 02, etc.)
|
||||
self.initial_retry_delay = 30 # Default initial retry delay in seconds
|
||||
self.retry_delay_increase = 30 # Default increase in delay between retries in seconds
|
||||
self.max_retries = 5 # Default maximum number of retries per track
|
||||
self.save_cover: bool = False # Option to save a cover.jpg image
|
||||
# New: artist separator for joining multiple artists or album artists
|
||||
self.artist_separator: str = "; "
|
||||
@@ -104,7 +104,13 @@ class EASY_DW:
|
||||
self.__link = preferences.link
|
||||
self.__output_dir = preferences.output_dir
|
||||
self.__song_metadata = preferences.song_metadata
|
||||
self.__song_metadata_dict = _track_object_to_dict(self.__song_metadata)
|
||||
# Convert song metadata to dict with configured artist separator
|
||||
artist_separator = getattr(preferences, 'artist_separator', '; ')
|
||||
if parent == 'album' and hasattr(self.__song_metadata, 'album'):
|
||||
# When iterating album tracks later we will still need the separator, but for initial dict, use track conversion
|
||||
self.__song_metadata_dict = track_object_to_dict(self.__song_metadata, source_type='spotify', artist_separator=artist_separator)
|
||||
else:
|
||||
self.__song_metadata_dict = track_object_to_dict(self.__song_metadata, source_type='spotify', artist_separator=artist_separator)
|
||||
self.__not_interface = preferences.not_interface
|
||||
self.__quality_download = preferences.quality_download or "NORMAL"
|
||||
self.__recursive_download = preferences.recursive_download
|
||||
@@ -136,6 +142,8 @@ class EASY_DW:
|
||||
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
|
||||
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
|
||||
pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
|
||||
# Ensure the separator is available to formatting utils for indexed placeholders
|
||||
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
self.__song_path = set_path(
|
||||
self.__song_metadata_dict,
|
||||
self.__output_dir,
|
||||
@@ -150,6 +158,7 @@ class EASY_DW:
|
||||
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
|
||||
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
|
||||
pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
|
||||
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
self.__song_path = set_path(
|
||||
self.__song_metadata_dict,
|
||||
self.__output_dir,
|
||||
@@ -200,7 +209,7 @@ class EASY_DW:
|
||||
parent_info = {
|
||||
"type": "album",
|
||||
"title": album_meta.title,
|
||||
"artist": "; ".join([a.name for a in album_meta.artists]),
|
||||
"artist": getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in album_meta.artists]),
|
||||
"total_tracks": total_tracks_val,
|
||||
"url": f"https://open.spotify.com/album/{album_meta.ids.spotify if album_meta.ids else ''}"
|
||||
}
|
||||
@@ -313,7 +322,7 @@ class EASY_DW:
|
||||
|
||||
except Exception as e:
|
||||
song_title = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
error_message = f"Download failed for '{song_title}' by '{artist_name}' (URL: {self.__link}). Original error: {str(e)}"
|
||||
logger.error(error_message)
|
||||
traceback.print_exc()
|
||||
@@ -334,7 +343,7 @@ class EASY_DW:
|
||||
# This handles cases where download_try didn't raise an exception but self.__c_track.success is still False.
|
||||
if hasattr(self, '_EASY_DW__c_track') and self.__c_track and not self.__c_track.success:
|
||||
song_title = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
original_error_msg = getattr(self.__c_track, 'error_message', "Download failed for an unspecified reason after attempt.")
|
||||
error_msg_template = "Cannot download '{title}' by '{artist}'. Reason: {reason}"
|
||||
final_error_msg = error_msg_template.format(title=song_title, artist=artist_name, reason=original_error_msg)
|
||||
@@ -362,7 +371,7 @@ class EASY_DW:
|
||||
def download_try(self) -> Track:
|
||||
current_title = self.__song_metadata.title
|
||||
current_album = self.__song_metadata.album.title if self.__song_metadata.album else ''
|
||||
current_artist = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
current_artist = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
|
||||
# Call the new check_track_exists function from skip_detection.py
|
||||
# It needs: original_song_path, title, album, convert_to, logger
|
||||
@@ -571,7 +580,7 @@ class EASY_DW:
|
||||
os.remove(self.__song_path)
|
||||
# Add track info to exception
|
||||
track_name = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}"
|
||||
# Store error on track object
|
||||
if hasattr(self, '_EASY_DW__c_track') and self.__c_track:
|
||||
@@ -736,7 +745,7 @@ class EASY_DW:
|
||||
if os.path.exists(self.__song_path):
|
||||
os.remove(self.__song_path) # Clean up partial file
|
||||
track_name = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}"
|
||||
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
|
||||
self.__c_episode.success = False
|
||||
@@ -788,7 +797,7 @@ class EASY_DW:
|
||||
pass
|
||||
unregister_active_download(self.__song_path)
|
||||
episode_title = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
final_error_msg = f"Error during real-time download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_realtime)}"
|
||||
logger.error(final_error_msg)
|
||||
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
|
||||
@@ -814,7 +823,7 @@ class EASY_DW:
|
||||
pass
|
||||
unregister_active_download(self.__song_path)
|
||||
episode_title = self.__song_metadata.title
|
||||
artist_name = "; ".join([a.name for a in self.__song_metadata.artists])
|
||||
artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
|
||||
final_error_msg = f"Error during standard download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_standard)}"
|
||||
logger.error(final_error_msg)
|
||||
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
|
||||
@@ -953,7 +962,8 @@ class DW_ALBUM:
|
||||
album.upc = album_obj.ids.upc
|
||||
tracks = album.tracks
|
||||
album.md5_image = self.__ids
|
||||
album.tags = _album_object_to_dict(self.__album_metadata) # For top-level album tags if needed
|
||||
album.tags = album_object_to_dict(self.__album_metadata, source_type='spotify', artist_separator=getattr(self.__preferences, 'artist_separator', '; ')) # For top-level album tags if needed
|
||||
album.tags['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
|
||||
|
||||
album_base_directory = get_album_directory(
|
||||
album.tags,
|
||||
|
||||
@@ -97,7 +97,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market: list[str] | None = stock_market
|
||||
market: list[str] | None = stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Track:
|
||||
song_metadata = None
|
||||
try:
|
||||
@@ -108,7 +109,7 @@ class SpoLogin:
|
||||
if song_metadata is None:
|
||||
raise Exception(f"Could not retrieve metadata for track {link_track}. It might not be available or an API error occurred.")
|
||||
|
||||
logger.info(f"Starting download for track: {song_metadata.title} - {'; '.join([a.name for a in song_metadata.artists])}")
|
||||
logger.info(f"Starting download for track: {song_metadata.title} - {artist_separator.join([a.name for a in song_metadata.artists])}")
|
||||
|
||||
preferences = Preferences()
|
||||
preferences.real_time_dl = real_time_dl
|
||||
@@ -135,6 +136,7 @@ class SpoLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
track = DW_TRACK(preferences).dw()
|
||||
|
||||
@@ -179,7 +181,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market: list[str] | None = stock_market
|
||||
market: list[str] | None = stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Album:
|
||||
try:
|
||||
link_is_valid(link_album)
|
||||
@@ -192,7 +195,7 @@ class SpoLogin:
|
||||
if song_metadata is None:
|
||||
raise Exception(f"Could not process album metadata for {link_album}. It might not be available in the specified market(s) or an API error occurred.")
|
||||
|
||||
logger.info(f"Starting download for album: {song_metadata.title} - {'; '.join([a.name for a in song_metadata.artists])}")
|
||||
logger.info(f"Starting download for album: {song_metadata.title} - {artist_separator.join([a.name for a in song_metadata.artists])}")
|
||||
|
||||
preferences = Preferences()
|
||||
preferences.real_time_dl = real_time_dl
|
||||
@@ -221,6 +224,7 @@ class SpoLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
album = DW_ALBUM(preferences).dw()
|
||||
|
||||
@@ -251,7 +255,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market: list[str] | None = stock_market
|
||||
market: list[str] | None = stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Playlist:
|
||||
try:
|
||||
link_is_valid(link_playlist)
|
||||
@@ -327,6 +332,7 @@ class SpoLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
playlist = DW_PLAYLIST(preferences).dw()
|
||||
|
||||
@@ -356,7 +362,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market: list[str] | None = stock_market
|
||||
market: list[str] | None = stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Episode:
|
||||
try:
|
||||
link_is_valid(link_episode)
|
||||
@@ -396,6 +403,7 @@ class SpoLogin:
|
||||
preferences.bitrate = bitrate
|
||||
preferences.save_cover = save_cover
|
||||
preferences.market = market
|
||||
preferences.artist_separator = artist_separator
|
||||
|
||||
episode = DW_EPISODE(preferences).dw()
|
||||
|
||||
@@ -428,7 +436,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
market: list[str] | None = stock_market,
|
||||
save_cover=stock_save_cover
|
||||
save_cover=stock_save_cover,
|
||||
artist_separator: str = "; "
|
||||
):
|
||||
"""
|
||||
Download all albums (or a subset based on album_type and limit) from an artist.
|
||||
@@ -468,7 +477,8 @@ class SpoLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
market=market,
|
||||
save_cover=save_cover
|
||||
save_cover=save_cover,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
downloaded_albums.append(downloaded_album)
|
||||
return downloaded_albums
|
||||
@@ -495,7 +505,8 @@ class SpoLogin:
|
||||
convert_to=None,
|
||||
bitrate=None,
|
||||
save_cover=stock_save_cover,
|
||||
market: list[str] | None = stock_market
|
||||
market: list[str] | None = stock_market,
|
||||
artist_separator: str = "; "
|
||||
) -> Smart:
|
||||
try:
|
||||
link_is_valid(link)
|
||||
@@ -528,7 +539,8 @@ class SpoLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market
|
||||
market=market,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "track"
|
||||
smart.track = track
|
||||
@@ -554,7 +566,8 @@ class SpoLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market
|
||||
market=market,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "album"
|
||||
smart.album = album
|
||||
@@ -580,7 +593,8 @@ class SpoLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market
|
||||
market=market,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "playlist"
|
||||
smart.playlist = playlist
|
||||
@@ -605,7 +619,8 @@ class SpoLogin:
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
save_cover=save_cover,
|
||||
market=market
|
||||
market=market,
|
||||
artist_separator=artist_separator
|
||||
)
|
||||
smart.type = "episode"
|
||||
smart.episode = episode
|
||||
|
||||
Reference in New Issue
Block a user