Add customizable music video naming and fix slskd log spam

Video naming: new path template in Settings → Paths & Organization
with $artist, $artistletter, $title, $year variables. Default
unchanged ($artist/$title-video → Artist/Title-video.mp4) so
existing Plex setups aren't affected. Users can remove the -video
suffix or reorganize however they like.

slskd logs: the Clean Search History automation now skips when
Soulseek is not the active download source, eliminating noisy
connection error logs for users who don't use Soulseek.
This commit is contained in:
Broque Thomas 2026-04-19 16:57:36 -07:00
parent a23bce5966
commit 122a6999b3
4 changed files with 52 additions and 10 deletions

View file

@ -1608,6 +1608,15 @@ def _register_automation_handlers():
def _auto_clean_search_history(config):
"""Remove old searches from Soulseek."""
automation_id = config.get('_automation_id')
# Skip if soulseek is not the active download source or in hybrid order
dl_mode = config_manager.get('download_source.mode', 'hybrid')
hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek'])
soulseek_active = (dl_mode == 'soulseek' or
(dl_mode == 'hybrid' and 'soulseek' in hybrid_order))
if not soulseek_active or not soulseek_client or not soulseek_client.base_url:
_update_automation_progress(automation_id,
log_line='Soulseek not active — skipped', log_type='skip')
return {'status': 'skipped'}
if not config_manager.get('soulseek.auto_clear_searches', True):
_update_automation_progress(automation_id,
log_line='Auto-clear disabled in settings', log_type='skip')
@ -9360,6 +9369,7 @@ def download_music_video():
_music_video_downloads[video_id]['status'] = 'matching'
artist_name = raw_channel
track_title = raw_title
year = ''
# Strip common YouTube suffixes for cleaner search
import re as _re
@ -9384,6 +9394,8 @@ def download_music_video():
if best and best_score >= 0.5:
artist_name = best.artists[0] if best.artists else raw_channel
track_title = best.name
if hasattr(best, 'release_date') and best.release_date:
year = str(best.release_date)[:4]
print(f"[Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})")
else:
# Parse artist from video title: "Artist - Title" pattern
@ -9403,13 +9415,27 @@ def download_music_video():
def _sanitize(s):
return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.')
artist_folder = _sanitize(artist_name)
video_filename = f"{_sanitize(track_title)}-video"
# Build output path: MusicVideos/Artist/Title-video
artist_dir = os.path.join(music_videos_path, artist_folder)
os.makedirs(artist_dir, exist_ok=True)
output_path = os.path.join(artist_dir, video_filename)
# Apply video path template
video_template = config_manager.get('file_organization.templates', {}).get('video_path', '$artist/$title-video')
if not video_template or not video_template.strip():
video_template = '$artist/$title-video'
safe_artist = _sanitize(artist_name)
video_path = video_template
video_path = video_path.replace('$artistletter', safe_artist[0].upper() if safe_artist else 'A')
video_path = video_path.replace('$artist', safe_artist)
video_path = video_path.replace('$title', _sanitize(track_title))
video_path = video_path.replace('$year', str(year) if year else '')
# Clean up empty segments from missing variables
video_path = _re.sub(r'//+', '/', video_path).strip('/')
# Split into folder and filename
path_parts = video_path.rsplit('/', 1)
if len(path_parts) == 2:
folder_part, file_part = path_parts
else:
folder_part, file_part = '', path_parts[0]
output_dir = os.path.join(music_videos_path, folder_part) if folder_part else music_videos_path
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, file_part)
# Step 2: Download
_music_video_downloads[video_id]['status'] = 'downloading'
@ -22503,6 +22529,8 @@ def get_version_info():
"• Reject Qobuz 30-second sample/preview downloads",
"• Fix library page crash on All filter — non-string soul_id broke card rendering",
"• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
"• Customizable music video naming — path template with $artist, $title, $year variables",
"• Fix soulseek log spam when not configured as download source",
],
},
{

View file

@ -5085,6 +5085,13 @@
<small class="settings-hint">Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only)</small>
</div>
<div class="form-group">
<label>Music Video Path Template:</label>
<input type="text" id="template-video-path"
placeholder="$artist/$title-video">
<small class="settings-hint">Variables: $artist, $artistletter, $title, $year. Default: $artist/$title-video → Artist/Title-video.mp4</small>
</div>
<div class="form-group">
<label>Multi-Disc Folder Label:</label>
<select id="disc-label">

View file

@ -3602,6 +3602,8 @@ const WHATS_NEW = {
'2.33': [
// --- April 19, 2026 ---
{ date: 'April 19, 2026' },
{ title: 'Customizable Music Video Naming', desc: 'Music video file naming is now configurable via a path template in Settings → Library → Paths & Organization. Default unchanged (Artist/Title-video.mp4). Remove "-video" from the template to get clean filenames. Available variables: $artist, $artistletter, $title, $year', page: 'settings' },
{ title: 'Fix Soulseek Log Spam', desc: 'The "Clean Search History" automation no longer tries to connect to slskd when Soulseek is not the active download source, eliminating noisy connection error logs for users who don\'t use Soulseek' },
{ title: 'Auto Wing It Discovery Fallback', desc: 'When playlist discovery fails to match a track on any metadata API (Spotify, Deezer, iTunes, etc.), the track now automatically falls back to Wing It mode instead of being marked "Not Found". Stub metadata is built from the raw source title and artist, and the track flows through the normal download pipeline via Soulseek. Amber "Wing It" badge distinguishes these from API-matched tracks. Works across all discovery sources: YouTube, Tidal, Deezer, Beatport, ListenBrainz, and mirrored playlists. Wing It stubs persist in the DB for mirrored playlists and are re-attempted on future discovery runs so real matches can replace them' },
{ title: 'Fix Library Page Crash on All Filter', desc: 'Library page could crash with "No artists found" when viewing all artists if any artist had a non-string soul_id. Individual letter filters worked because the problematic artist wasn\'t in those results. Card rendering is now fault-tolerant — one bad artist card can\'t take down the whole page', page: 'library' },
{ title: 'Fix CI Test Failures', desc: 'Fixed test suite failures caused by incomplete dummy config managers missing get_active_media_server() and script.js read encoding on non-UTF-8 locales' },

View file

@ -5652,12 +5652,14 @@ function resetFileOrganizationTemplates() {
const defaults = {
album: '$albumartist/$albumartist - $album/$track - $title',
single: '$artist/$artist - $title/$title',
playlist: '$playlist/$artist - $title'
playlist: '$playlist/$artist - $title',
video: '$artist/$title-video'
};
document.getElementById('template-album-path').value = defaults.album;
document.getElementById('template-single-path').value = defaults.single;
document.getElementById('template-playlist-path').value = defaults.playlist;
document.getElementById('template-video-path').value = defaults.video;
debouncedAutoSaveSettings();
}
@ -5669,7 +5671,8 @@ function validateFileOrganizationTemplates() {
const validVars = {
album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$year', '$quality'],
single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'],
playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality']
playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'],
video: ['$artist', '$artistletter', '$title', '$year']
};
// Get template values
@ -6191,6 +6194,7 @@ async function loadSettingsData() {
document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title';
document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title';
document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title';
document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video';
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first';
document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false;
@ -7645,7 +7649,8 @@ async function saveSettings(quiet = false) {
templates: {
album_path: document.getElementById('template-album-path').value,
single_path: document.getElementById('template-single-path').value,
playlist_path: document.getElementById('template-playlist-path').value
playlist_path: document.getElementById('template-playlist-path').value,
video_path: document.getElementById('template-video-path').value
}
},
wishlist: {