Add customizable file organization templates

Introduces a template-based file organization system for downloads, allowing users to define custom folder and filename structures for albums, singles, and playlists. Updates the backend, config example, web UI, and client-side validation to support template editing, resetting, and error checking. Improves consistency in file placement and metadata handling across all download modes.
pull/97/head
Broque Thomas 2 months ago
parent 5e866463ea
commit c7e943034d

@ -4,284 +4,235 @@
# 🎵 SoulSync - Automated Music Discovery & Collection Manager
Bridge the gap between streaming services and your local music library. Automatically sync Spotify/Tidal/YouTube playlists to Plex/Jellyfin/Navidrome via Soulseek.
**Bridge streaming services to your local music library.** Automatically sync Spotify/Tidal/YouTube playlists to Plex/Jellyfin/Navidrome via Soulseek with intelligent matching, metadata enhancement, and automated discovery.
> 📢 **Development Notice**: New features and major updates are currently being developed exclusively for the **Web UI** version. The original **Desktop GUI** version will continue to receive maintenance and bug fixes to ensure stability, but new functionality will only be added to the Web UI going forward. If the Web UI version ever feels 'completed', I'll likely add those missing features to the GUI version.
> ⚠️ **CRITICAL**: Configure file sharing in slskd before use. Users who only download without sharing get banned by the Soulseek community. Set up shared folders at `http://localhost:5030/shares`.
> ⚠️ **CRITICAL**: You MUST configure file sharing in slskd before using SoulSync. Users who only download without sharing get banned by the Soulseek community. Set up shared folders in slskd's web interface at `http://localhost:5030/shares` - share your music library or downloads folder.
> 📢 **Development Focus**: New features are developed for the **Web UI** version. The Desktop GUI receives maintenance and bug fixes only.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/boulderbadgedad)
## ✨ Core Features
## 🆕 Recent Updates
**Sync & Download**
- Auto-sync playlists from Spotify/Tidal/YouTube to your media server
- Smart matching against your existing library
- FLAC-priority downloads from Soulseek with automatic fallback
- Customizable file organization with template-based path structures
- Synchronized lyrics (LRC) for every track via LRClib.net
- **Log level control** - Change between DEBUG/INFO/WARNING/ERROR in settings without restart
- **Jellyfin library selector** - Choose which music library to use (matches Plex functionality)
- **Enhanced wishlist management** - Remove individual tracks or entire albums with beautiful confirmation modals
- **Docker config persistence** - Config now properly saves between container restarts
**Metadata & Organization**
- Enhanced metadata with album art and proper tags
- Flexible folder templates: `$albumartist/$album/$track - $title`
- Automatic library scanning and database updates
- Clean, organized music collection
## ✨ What It Does
**Discovery & Automation**
- Browse complete artist discographies with similar artist recommendations
- Intelligent music discovery using your watchlist ([music-map.com](https://music-map.com) integration)
- Curated playlists: Release Radar, Discovery Weekly, Seasonal Mixes
- Beatport chart integration for electronic music
- Artist watchlist monitors new releases automatically
- **Auto-sync playlists** from Spotify/Tidal/YouTube to your media server
- **Smart matching** finds what you're missing vs what you own
- **Download missing tracks** from Soulseek with FLAC priority
- **Metadata enhancement** adds proper tags and album art
- **Automatic lyrics** synchronized LRC files for every download
- **Auto server scanning** triggers library scans after downloads
- **Auto database updates** keeps SoulSync database current
- **File organization** creates clean folder structures
- **Artist discovery** browse complete discographies with similar artists recommendations powered by [music-map.com](https://music-map.com)
- **Music library browser** comprehensive collection management with search and completion tracking
- **Wishlist system** saves failed downloads for automatic retry with granular track/album removal
- **Artist watchlist** monitors for new releases and adds missing tracks
- **Discover page** intelligent music discovery using your watchlist to curate personalized playlists
- **Background automation** retries failed downloads every hour
- **Dynamic logging** adjust log verbosity on the fly for easier debugging
**Management**
- Comprehensive library browser with search and completion tracking
- Wishlist system with automatic retry (30-minute intervals)
- Granular wishlist management (remove individual tracks or entire albums)
- Dynamic log level control (DEBUG/INFO/WARNING/ERROR)
- Background automation handles retries and database updates
## 🚀 Installation
## Star History
### Docker (Recommended)
```bash
# Using docker-compose
curl -O https://raw.githubusercontent.com/Nezreka/SoulSync/main/docker-compose.yml
docker-compose up -d
[![Star History Chart](https://api.star-history.com/svg?repos=Nezreka/SoulSync&type=date&legend=top-left)](https://www.star-history.com/#Nezreka/SoulSync&type=date&legend=top-left)
# Or run directly
docker run -d -p 8008:8008 boulderbadgedad/soulsync:latest
## 🚀 Three Ways to Run
# Access at http://localhost:8008
```
### 1. Desktop GUI (Original)
Full PyQt6 desktop application with all features.
### Web UI (Python)
```bash
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
pip install -r requirements.txt
python main.py
```
### 2. Web UI (New!)
Browser-based interface - same features, runs anywhere.
```bash
python web_server.py
# Open http://localhost:8008
```
### 3. Docker (New!)
Containerized web UI with persistent database.
### Desktop GUI
```bash
# Option 1: Use docker-compose (recommended)
curl -O https://raw.githubusercontent.com/Nezreka/SoulSync/main/docker-compose.yml
docker-compose up -d
# Option 2: Run directly
docker run -d -p 8008:8008 boulderbadgedad/soulsync:latest
# Open http://localhost:8008
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
pip install -r requirements.txt
python main.py
```
## ⚡ Quick Setup
### Prerequisites
- **slskd**: Download from [GitHub](https://github.com/slskd/slskd/releases), run on port 5030
- **Spotify API**: Get Client ID/Secret (see setup below)
- **Tidal API**: Get Client ID/Secret (see setup below)
- **Media Server**: Plex, Jellyfin, or Navidrome (optional but recommended)
## 🔑 API Setup Guide
### Spotify API Setup
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. Click **"Create App"**
3. Fill out the form:
- **App Name**: `SoulSync` (or whatever you want)
- **App Description**: `Music library sync`
- **Website**: `http://localhost` (or leave blank)
- **Redirect URI**: `http://127.0.0.1:8888/callback`
4. Click **"Save"**
5. Click **"Settings"** on your new app
6. Copy the **Client ID** and **Client Secret**
### Tidal API Setup
1. Go to [Tidal Developer Dashboard](https://developer.tidal.com/dashboard)
2. Click **"Create New App"**
3. Fill out the form:
- **App Name**: `SoulSync`
- **Description**: `Music library sync`
- **Redirect URI**: `http://127.0.0.1:8889/callback`
- **Scopes**: Select `user.read` and `playlists.read`
4. Click **"Save"**
5. Copy the **Client ID** and **Client Secret**
### Plex Token Setup
**Easy Method:**
1. Open Plex in your browser and sign in
2. Go to any movie/show page
3. Click **"Get Info"** or three dots menu → **"View XML"**
4. In the URL bar, copy everything after `X-Plex-Token=`
- Example: `http://192.168.1.100:32400/library/metadata/123?X-Plex-Token=YOUR_TOKEN_HERE`
5. Your Plex server URL is typically `http://YOUR_IP:32400`
**Alternative Method:**
1. Go to [plex.tv/claim](https://plex.tv/claim) while logged in
2. Your 4-minute claim token appears - this isn't what you need
3. Instead, right-click → Inspect → Network tab → Reload page
4. Look for requests with `X-Plex-Token` header and copy that value
### Navidrome Setup
**Easy Method:**
1. Open your Navidrome web interface and sign in
2. Go to **Settings** → **Users**
3. Click on your user account
4. Under **Token**, click **"Generate API Token"**
5. Copy the generated token
6. Your Navidrome server URL is typically `http://YOUR_IP:4533`
**Using Username/Password:**
- You can also use your regular username and password instead of a token
- SoulSync supports both authentication methods for Navidrome
### Jellyfin Setup
1. Open your Jellyfin web interface and sign in
2. Go to **Settings** → **API Keys**
3. Click **"+"** to create a new API key
4. Give it a name like "SoulSync"
5. Copy the generated API key
6. Your Jellyfin server URL is typically `http://YOUR_IP:8096`
7. **New**: After connecting, select which music library to use from the dropdown (if you have multiple)
### Final Steps
1. Set up slskd with downloads folder and API key
2. Launch SoulSync, go to Settings, enter all your API credentials
3. Configure your download and transfer folder paths
4. **Important**: Share music in slskd to avoid bans
### Docker Notes
- **Config persistence**: Config now properly mounted at `./config:/app/config` - settings persist between restarts
- **Database persistence**: Uses named volume for database (separate from GUI/WebUI versions)
- **Multi-library support**: Plex and Jellyfin can select which music library to use via dropdown in settings
- **Drive mounting**: Mount drives containing your download/transfer folders:
```yaml
volumes:
- ./config:/app/config # Config persistence
- ./logs:/app/logs # Log files
- /mnt/c:/host/mnt/c:rw # For C: drive paths
- /mnt/d:/host/mnt/d:rw # For D: drive paths
- /mnt/h:/host/mnt/h:rw # Add drives as needed
```
- **Path format**: Use `/host/mnt/X/path` in settings where X is your drive letter
- **Troubleshooting**: If drive mounts fail, try restarting Docker Desktop and ensure drives are shared in Docker Desktop settings
### Docker OAuth Fix (Remote Access)
If accessing SoulSync from a different machine than where it's running:
1. Set your Spotify callback URL to `http://127.0.0.1:8888/callback`
2. Open SoulSync settings and click authenticate
3. Complete Spotify authorization - you'll be redirected to `http://127.0.0.1:8888/callback?code=SOME_CODE_HERE`
4. If the page fails to load, edit the URL to use your actual SoulSync IP:
- Change: `http://127.0.0.1:8888/callback?code=SOME_CODE_HERE`
- To: `http://192.168.1.5:8888/callback?code=SOME_CODE_HERE`
5. Press Enter and authentication should complete
**Note**: Spotify only allows `127.0.0.1` as a local redirect URI, hence this workaround. You may need to repeat this process after rebuilding containers.
## 🎵 Beatport Integration
Discover the hottest dance music with our fresh Beatport integration. Whether you're following superstar DJs or hunting for underground gems, SoulSync pulls directly from Beatport's extensive catalog.
**Chart Explorer**: Browse featured charts, DJ curated sets, and trending tracks
**Genre Deep Dive**: Discover new releases and popular tracks by genre
**One-Click Downloads**: Grab entire charts or individual tracks instantly
**Premium Discovery**: Access the same charts that DJs use to find their next big tracks
Just hit up the Beatport section in the web UI and start exploring. Perfect for DJs building sets or anyone who wants to stay ahead of the curve on electronic music trends.
## 🎧 Discover Page
Personalized music discovery powered by your watchlist. Add artists you like, and SoulSync automatically builds a database of similar artists using [music-map.com](https://music-map.com) to curate fresh playlists.
**Three Smart Playlists:**
- **Release Radar**: New releases (past 7 days) from watchlist, similar artists, and your library
- **Discovery Weekly**: Broader selection of recent releases you might have missed
- **Featured Artists**: Hero slideshow of recommended similar artists with one-click watchlist/discography access
**How it works:** Watchlist → Similar Artists DB → New Releases → Personalized Playlists
Unlike generic algorithms, recommendations are based only on artists you explicitly watch, using actual music relationships instead of popularity trends.
## 📁 File Flow
1. **Search**: Query Soulseek via slskd API
2. **Download**: Files saved to configured download folder
3. **Process**: Auto-organize to transfer folder with metadata enhancement
4. **Lyrics**: Automatic LRC file generation using LRClib.net API
5. **Server Scan**: Triggers library scan on your media server (60s delay)
6. **Database Sync**: Updates SoulSync database with new tracks
7. **Structure**: `Transfer/Artist/Artist - Album/01 - Track.flac` + `01 - Track.lrc`
8. **Import**: Media server picks up organized files with lyrics
## 🔧 Config Example
```json
{
"spotify": {
"client_id": "your_client_id",
"client_secret": "your_client_secret"
},
"plex": {
"base_url": "http://localhost:32400",
"token": "your_plex_token"
},
"jellyfin": {
"base_url": "http://localhost:8096",
"api_key": "your_jellyfin_api_key"
},
"navidrome": {
"base_url": "http://localhost:4533",
"username": "your_username",
"password": "your_password"
},
"soulseek": {
"slskd_url": "http://localhost:5030",
"api_key": "your_api_key",
"download_path": "/path/to/downloads",
"transfer_path": "/path/to/music/library"
}
}
- **slskd**: [Download](https://github.com/slskd/slskd/releases), run on port 5030
- **Spotify API**: Client ID/Secret from [Developer Dashboard](https://developer.spotify.com/dashboard)
- **Tidal API** (optional): Client ID/Secret from [Developer Dashboard](https://developer.tidal.com/dashboard)
- **Media Server** (optional): Plex, Jellyfin, or Navidrome
### API Credentials
**Spotify**
1. [Create app](https://developer.spotify.com/dashboard) → Settings
2. Add redirect URI: `http://127.0.0.1:8888/callback`
3. Copy Client ID and Secret
**Tidal**
1. [Create app](https://developer.tidal.com/dashboard)
2. Add redirect URI: `http://127.0.0.1:8889/callback`
3. Add scopes: `user.read`, `playlists.read`
4. Copy Client ID and Secret
**Plex**
- Get token from any media item URL: `?X-Plex-Token=YOUR_TOKEN`
- Server URL: `http://YOUR_IP:32400`
**Jellyfin**
- Settings → API Keys → Generate new key
- Server URL: `http://YOUR_IP:8096`
**Navidrome**
- Settings → Users → Generate API Token
- Or use username/password
- Server URL: `http://YOUR_IP:4533`
### Configuration
1. Launch SoulSync and go to Settings
2. Enter API credentials for streaming services and media server
3. Configure slskd URL (`http://localhost:5030`) and API key
4. Set download and transfer paths
5. **Customize file organization** (optional):
- Enable custom templates in Settings → File Organization
- Default: `$albumartist/$albumartist - $album/$track - $title`
- Variables: `$artist`, `$albumartist`, `$album`, `$title`, `$track`, `$playlist`
- Example: `Music/$artist/$year - $album/$track - $title`
6. **Share files in slskd** to avoid bans
## 📁 File Organization
SoulSync supports customizable path templates with validation and fallback protection.
**Default Structure**
```
Transfer/
Artist/
Artist - Album/
01 - Track.flac
01 - Track.lrc
```
**Template System**
- **Albums**: `$albumartist/$albumartist - $album/$track - $title`
- **Singles**: `$artist/$artist - $title/$title`
- **Playlists**: `$playlist/$artist - $title`
**Available Variables**
- `$artist`, `$albumartist`, `$album`, `$title`
- `$track` (zero-padded: 01, 02...)
- `$playlist` (playlist name)
**Features**
- Client-side validation prevents invalid templates
- Reset to defaults button in settings
- Automatic fallback if template fails
- Changes apply immediately to new downloads
## 🐳 Docker Notes
**Path Configuration**
```yaml
volumes:
- ./config:/app/config # Settings persist
- ./logs:/app/logs # Log files
- /mnt/c:/host/mnt/c:rw # Mount Windows drives
- /mnt/d:/host/mnt/d:rw
```
## ⚠️ Important Notes
Use `/host/mnt/X/path` in settings where X is your drive letter.
**OAuth from Remote Devices**
When accessing from a different machine, Spotify redirects may fail:
1. Complete OAuth flow - get redirected to `http://127.0.0.1:8888/callback?code=...`
2. Edit URL to use your server IP: `http://192.168.1.5:8888/callback?code=...`
3. Press Enter to complete authentication
See [DOCKER-OAUTH-FIX.md](DOCKER-OAUTH-FIX.md) for details.
## 📊 Workflow
1. **Sync**: Select Spotify/Tidal/YouTube playlist
2. **Match**: SoulSync compares against your library
3. **Download**: Missing tracks queued from Soulseek
4. **Process**: Files enhanced with metadata, lyrics, and album art
5. **Organize**: Moved to transfer folder with template-based structure
6. **Scan**: Media server automatically rescans library
7. **Update**: SoulSync database syncs with your collection
- **Must share files in slskd** - downloaders without shares get banned
- **Docker uses separate database** from GUI/WebUI versions
- **Transfer path** should point to your media server music library
- **FLAC preferred** but supports all common formats
- **OAuth from different devices:** See [DOCKER-OAUTH-FIX.md](DOCKER-OAUTH-FIX.md) if you get "Insecure redirect URI" errors
## 🐛 Troubleshooting
## 🐛 Debugging & Troubleshooting
**Enable Debug Logging**
- Settings → Log Level → DEBUG
- Check `logs/app.log` for detailed information
- Change takes effect immediately
**Enable Debug Logging:**
- Go to Settings → scroll to "Log Level" dropdown
- Change from INFO to DEBUG
- Takes effect immediately (no restart needed)
- Check logs at `logs/app.log` for detailed information
**Common Issues**
**Common Issues:**
*Files not organizing properly*
- Verify transfer path points to your music library
- Check template syntax in Settings → File Organization
- Use "Reset to Defaults" if templates are broken
- Review logs for path-related errors
- **Files not moving from downloads to transfer folder**
- Check that transfer path points to your music library
- In Docker: Ensure the drive is mounted (see Docker Notes above)
- Verify permissions on both download and transfer folders
*Docker drive access*
- Ensure drives are mounted in docker-compose.yml
- Restart Docker Desktop if mounts fail
- Verify paths use `/host/mnt/X/` prefix
- **Plex/Jellyfin connection issues**
- Test connection in settings to verify credentials
- For multi-library setups: Select the correct music library from dropdown
- Check that server URL is accessible from SoulSync
*Wishlist tracks stuck*
- Remove items using delete buttons on wishlist page
- Auto-retry runs every 30 minutes
- Check logs for download failures
- **Wishlist tracks stuck**
- Use the new delete buttons (hover over tracks/albums) to remove stuck items
- Auto-retry runs every 30 minutes for wishlist items
*Multi-library setups*
- Select correct library from dropdown in settings (Plex/Jellyfin)
- Test connection to verify credentials
## 🏗️ Architecture
- **Core**: Service clients for Spotify, Plex, Jellyfin, Navidrome, Soulseek
- **Database**: SQLite with full media library cache and automatic updates
- **UI**: PyQt6 desktop + Flask web interface
- **Matching**: Advanced text normalization and scoring
- **Lyrics**: LRClib.net integration for synchronized lyrics
- **Automation**: Multi-threaded with automatic retry, scanning, and database updates
- **Services**: Spotify, Tidal, Plex, Jellyfin, Navidrome, Soulseek clients
- **Database**: SQLite with automatic library caching and updates
- **UI**: PyQt6 Desktop + Flask Web Interface
- **Matching**: Advanced text normalization and fuzzy scoring
- **Metadata**: Mutagen + LRClib.net for tags and lyrics
- **Automation**: Multi-threaded with retry logic and background tasks
Modern, clean, automated. Set it up once, let it manage your music library.
## 📝 Recent Updates
- **Customizable file organization** with template-based paths and validation
- **Log level control** without restart
- **Jellyfin library selector** for multi-library setups
- **Enhanced wishlist management** with track/album removal
- **Docker config persistence** between container restarts
---
<p align="center">
<a href="https://ko-fi.com/boulderbadgedad">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi">
</a>
</p>
<p align="center">
<a href="https://star-history.com/#Nezreka/SoulSync&type=date&legend=top-left">
<img src="https://api.star-history.com/svg?repos=Nezreka/SoulSync&type=date&legend=top-left" alt="Star History">
</a>
</p>

@ -44,6 +44,15 @@
"enabled": true,
"embed_album_art": true
},
"file_organization": {
"enabled": true,
"templates": {
"album_path": "$albumartist/$albumartist - $album/$track - $title",
"single_path": "$artist/$artist - $title/$title",
"compilation_path": "Compilations/$album/$track - $artist - $title",
"playlist_path": "$playlist/$artist - $title"
}
},
"playlist_sync": {
"create_backup": true
},

@ -2058,7 +2058,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'playlist_sync', 'tidal']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -6447,6 +6447,203 @@ def parse_youtube_playlist(url):
# ===================================================================
# FILE ORGANIZATION TEMPLATE ENGINE
# ===================================================================
def _build_final_path_for_track(context, spotify_artist, album_info, file_ext):
"""
SHARED PATH BUILDER - Used by both post-processing AND verification.
This ensures they always produce the same path.
Returns: (final_path, folder_created_successfully)
"""
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
track_info = context.get("track_info", {})
original_search = context.get("original_search_result", {})
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
# Determine which template type to use
if playlist_folder_mode:
# PLAYLIST MODE
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
template_context = {
'artist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'albumartist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'album': track_name,
'title': track_name,
'playlist_name': playlist_name,
'track_number': 1
}
folder_path, filename_base = _get_file_path_from_template(template_context, 'playlist_path')
if folder_path and filename_base:
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
return final_path, True
else:
# Fallback
playlist_name_sanitized = _sanitize_filename(playlist_name)
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
os.makedirs(playlist_dir, exist_ok=True)
artist_name_sanitized = _sanitize_filename(template_context['artist'])
track_name_sanitized = _sanitize_filename(track_name)
new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}"
return os.path.join(playlist_dir, new_filename), True
elif album_info and album_info.get('is_album'):
# ALBUM MODE
clean_track_name = album_info.get('clean_track_name', 'Unknown Track')
if original_search.get('spotify_clean_title'):
clean_track_name = original_search['spotify_clean_title']
elif album_info.get('clean_track_name'):
clean_track_name = album_info['clean_track_name']
else:
clean_track_name = original_search.get('title', 'Unknown Track')
track_number = album_info.get('track_number', 1)
if track_number is None or not isinstance(track_number, int) or track_number < 1:
track_number = 1
template_context = {
'artist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'albumartist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'album': album_info['album_name'],
'title': clean_track_name,
'track_number': track_number
}
folder_path, filename_base = _get_file_path_from_template(template_context, 'album_path')
if folder_path and filename_base:
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
return final_path, True
else:
# Fallback
artist_name_sanitized = _sanitize_filename(template_context['artist'])
album_name_sanitized = _sanitize_filename(album_info['album_name'])
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}"
album_dir = os.path.join(artist_dir, album_folder_name)
os.makedirs(album_dir, exist_ok=True)
final_track_name_sanitized = _sanitize_filename(clean_track_name)
new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}"
return os.path.join(album_dir, new_filename), True
else:
# SINGLE MODE
clean_track_name = album_info.get('clean_track_name', 'Unknown Track') if album_info else 'Unknown Track'
if original_search.get('spotify_clean_title'):
clean_track_name = original_search['spotify_clean_title']
elif album_info and album_info.get('clean_track_name'):
clean_track_name = album_info['clean_track_name']
else:
clean_track_name = original_search.get('title', 'Unknown Track')
template_context = {
'artist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'albumartist': spotify_artist["name"] if isinstance(spotify_artist, dict) else spotify_artist.name,
'album': album_info.get('album_name', clean_track_name) if album_info else clean_track_name,
'title': clean_track_name,
'track_number': 1
}
folder_path, filename_base = _get_file_path_from_template(template_context, 'single_path')
if folder_path and filename_base:
final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext)
os.makedirs(os.path.join(transfer_dir, folder_path), exist_ok=True)
return final_path, True
else:
# Fallback
artist_name_sanitized = _sanitize_filename(template_context['artist'])
final_track_name_sanitized = _sanitize_filename(clean_track_name)
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}"
single_dir = os.path.join(artist_dir, single_folder_name)
os.makedirs(single_dir, exist_ok=True)
new_filename = f"{final_track_name_sanitized}{file_ext}"
return os.path.join(single_dir, new_filename), True
def _apply_path_template(template: str, context: dict) -> str:
"""
Apply template to build file path.
Args:
template: Template string like "$artist/$album/$track - $title"
context: Dict with values like {'artist': 'Drake', 'album': 'Scorpion', ...}
Returns:
Processed path string
"""
result = template
# Replace variables in order from longest to shortest to avoid partial replacements
# (e.g., $albumartist must be replaced before $album to prevent "Scorpionartist" from typo "$albumartis")
# Longest variables first
result = result.replace('$albumartist', context.get('albumartist', context.get('artist', 'Unknown Artist')))
result = result.replace('$playlist', context.get('playlist_name', ''))
# Medium length variables
result = result.replace('$artist', context.get('artist', 'Unknown Artist'))
result = result.replace('$album', context.get('album', 'Unknown Album'))
result = result.replace('$title', context.get('title', 'Unknown Track'))
result = result.replace('$track', f"{context.get('track_number', 1):02d}")
return result
def _get_file_path_from_template(context: dict, template_type: str = 'album_path') -> tuple:
"""
Build complete file path using configured templates.
Args:
context: Dict with all track/album metadata
template_type: 'album_path', 'single_path', 'compilation_path', 'playlist_path'
Returns:
(folder_path, filename) tuple
"""
# Check if template system is enabled
if not config_manager.get('file_organization.enabled', True):
# Fallback to hardcoded structure
return None, None
# Get template from config
templates = config_manager.get('file_organization.templates', {})
template = templates.get(template_type)
if not template:
# Fallback templates if config missing
default_templates = {
'album_path': '$albumartist/$albumartist - $album/$track - $title',
'single_path': '$artist/$artist - $title/$title',
'compilation_path': 'Compilations/$album/$track - $artist - $title',
'playlist_path': '$playlist/$artist - $title'
}
template = default_templates.get(template_type, '$artist/$album/$track - $title')
# Apply template
full_path = _apply_path_template(template, context)
# Split into folder and filename
path_parts = full_path.split('/')
if len(path_parts) > 1:
folder_parts = path_parts[:-1]
filename_base = path_parts[-1]
# Sanitize each folder component
sanitized_folders = [_sanitize_filename(part) for part in folder_parts]
folder_path = os.path.join(*sanitized_folders)
# Sanitize filename
filename = _sanitize_filename(filename_base)
return folder_path, filename
else:
# Single component, treat as filename
return '', _sanitize_filename(full_path)
# METADATA & COVER ART HELPERS (Ported from downloads.py)
# ===================================================================
from mutagen import File as MutagenFile
@ -6783,76 +6980,61 @@ def _post_process_matched_download_with_verification(context_key, context, file_
context['batch_id'] = original_batch_id
# CRITICAL VERIFICATION STEP: Verify the final file exists
# Extract the expected final path from the context or reconstruct it
spotify_artist = context.get("spotify_artist")
if not spotify_artist:
raise Exception("Missing spotify_artist context for verification")
# Check if playlist folder mode is enabled
track_info = context.get("track_info", {})
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
# Handle playlist folder mode verification
if playlist_folder_mode:
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
playlist_name_sanitized = _sanitize_filename(playlist_name)
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
# Get album info for path calculation
is_album_download = context.get("is_album_download", False)
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
if is_album_download and has_clean_spotify_data:
original_search = context.get("original_search_result", {})
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
track_name_sanitized = _sanitize_filename(track_name)
artist_name_sanitized = _sanitize_filename(spotify_artist.get("name", "Unknown Artist") if isinstance(spotify_artist, dict) else spotify_artist.name)
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}"
expected_final_path = os.path.join(playlist_dir, new_filename)
print(f"📁 [Verification - Playlist Mode] Expected path: {expected_final_path}")
spotify_album = context.get("spotify_album", {})
clean_track_name = original_search.get('spotify_clean_title', 'Unknown Track')
clean_album_name = original_search.get('spotify_clean_album', 'Unknown Album')
album_info = {
'is_album': True,
'album_name': clean_album_name,
'track_number': original_search.get('track_number', 1),
'clean_track_name': clean_track_name,
'album_image_url': spotify_album.get('image_url'),
'confidence': 1.0,
'source': 'clean_spotify_metadata'
}
elif is_album_download:
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
clean_track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
album_name = (original_search.get('spotify_clean_album') or
spotify_album.get('name') or
'Unknown Album')
album_info = {
'is_album': True,
'album_name': album_name,
'track_number': original_search.get('track_number', 1),
'clean_track_name': clean_track_name,
'album_image_url': spotify_album.get('image_url'),
'confidence': 0.9,
'source': 'enhanced_fallback_album_context'
}
else:
# Original album/artist folder verification logic
is_album_download = context.get("is_album_download", False)
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
album_info = _detect_album_info_web(context, spotify_artist)
if is_album_download and has_clean_spotify_data:
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
clean_track_name = original_search.get('spotify_clean_title', 'Unknown Track')
clean_album_name = original_search.get('spotify_clean_album', 'Unknown Album')
track_number = original_search.get('track_number', 1)
# Construct the expected final path
artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
album_name_sanitized = clean_album_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
track_name_sanitized = clean_track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
album_folder_name = f"{clean_album_name} ({spotify_album.get('release_date', '').split('-')[0] if spotify_album.get('release_date') else 'Unknown'})"
album_folder_name = album_folder_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
album_dir = os.path.join(artist_dir, album_folder_name)
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{track_number:02d} - {track_name_sanitized}{file_ext}"
expected_final_path = os.path.join(album_dir, new_filename)
# Apply album grouping if needed
if album_info and album_info.get('is_album'):
original_album = None
if context.get("original_search_result", {}).get("album"):
original_album = context["original_search_result"]["album"]
consistent_album_name = _resolve_album_group(spotify_artist, album_info, original_album)
album_info['album_name'] = consistent_album_name
else:
# For singles or fallback logic
original_search = context.get("original_search_result", {})
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
track_name_sanitized = track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
# Use shared path builder to get expected final path
file_ext = os.path.splitext(file_path)[1]
expected_final_path, _ = _build_final_path_for_track(context, spotify_artist, album_info, file_ext)
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
single_dir = os.path.join(artist_dir, "Singles")
print(f"📁 [Verification] Expected final path: {expected_final_path}")
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{track_name_sanitized}{file_ext}"
expected_final_path = os.path.join(single_dir, new_filename)
# VERIFICATION: Check if file exists at expected final path
if os.path.exists(expected_final_path):
print(f"✅ [Verification] File verified at final path: {expected_final_path}")
@ -6941,28 +7123,13 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"🔍 [Debug] Post-processing - track_info keys: {list(track_info.keys())}")
if playlist_folder_mode:
# Use playlist folder structure: Transfer/Playlist Name/Artist - Track.ext
# Use shared path builder for playlist mode
playlist_name = track_info.get("_playlist_name", "Unknown Playlist")
print(f"📁 [Playlist Folder Mode] Organizing in playlist folder: {playlist_name}")
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
playlist_name_sanitized = _sanitize_filename(playlist_name)
playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized)
os.makedirs(playlist_dir, exist_ok=True)
# Get track name from context
original_search = context.get("original_search_result", {})
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
track_name_sanitized = _sanitize_filename(track_name)
artist_name_sanitized = _sanitize_filename(spotify_artist["name"])
# Create filename: Artist - Track.ext
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}"
final_path = os.path.join(playlist_dir, new_filename)
print(f"📁 Playlist folder: '{playlist_name_sanitized}'")
print(f"🎵 Track filename: '{new_filename}'")
final_path, _ = _build_final_path_for_track(context, spotify_artist, None, file_ext)
print(f"📁 Playlist mode final path: '{final_path}'")
# Move file to playlist folder
print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'")
@ -7123,18 +7290,12 @@ def _post_process_matched_download(context_key, context, file_path):
# CRITICAL FIX: Update album_info with corrected track_number for metadata enhancement
album_info['track_number'] = track_number
album_info['clean_track_name'] = clean_track_name # Ensure clean name is in album_info
print(f"✅ [FIX] Updated album_info track_number to {track_number} for consistent metadata")
album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}"
album_dir = os.path.join(artist_dir, album_folder_name)
os.makedirs(album_dir, exist_ok=True)
# Create track filename with number (just track number + clean title, NO artist)
new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}"
final_path = os.path.join(album_dir, new_filename)
print(f"📁 Album folder created: '{album_folder_name}'")
print(f"🎵 Track filename: '{new_filename}'")
# Use shared path builder for album mode
final_path, _ = _build_final_path_for_track(context, spotify_artist, album_info, file_ext)
print(f"📁 Album path: '{final_path}'")
else:
# Single track structure: Transfer/ARTIST/ARTIST - SINGLE/SINGLE.ext
# --- GUI PARITY: Use multiple sources for clean track name ---
@ -7154,16 +7315,13 @@ def _post_process_matched_download(context_key, context, file_path):
clean_track_name = original_search.get('title', 'Unknown Track')
print(f"🎵 Using original title as fallback: '{clean_track_name}'")
final_track_name_sanitized = _sanitize_filename(clean_track_name)
single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}"
single_dir = os.path.join(artist_dir, single_folder_name)
os.makedirs(single_dir, exist_ok=True)
# Create single filename with clean track name
new_filename = f"{final_track_name_sanitized}{file_ext}"
final_path = os.path.join(single_dir, new_filename)
print(f"📁 Single track: {single_folder_name}/{new_filename}")
# Ensure clean name is in album_info for path builder
if album_info:
album_info['clean_track_name'] = clean_track_name
# Use shared path builder for single mode
final_path, _ = _build_final_path_for_track(context, spotify_artist, album_info, file_ext)
print(f"📁 Single path: '{final_path}'")
# 3. Enhance metadata, move file, download art, and cleanup
_enhance_file_metadata(file_path, context, spotify_artist, album_info)
@ -12097,14 +12255,34 @@ def _add_cancelled_task_to_wishlist(task):
else:
formatted_artists.append({'name': str(artist)})
# Build album data with all available info
album_raw = track_info.get('album', {})
if isinstance(album_raw, dict):
album_data = {
'name': album_raw.get('name', 'Unknown Album'),
'album_type': track_info.get('album_type', 'album')
}
# Preserve images if present in album object
if 'images' in album_raw:
album_data['images'] = album_raw['images']
# Otherwise, try to get from album_image_url
elif track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
else:
# album is a string (album name)
album_data = {
'name': str(album_raw) if album_raw else 'Unknown Album',
'album_type': track_info.get('album_type', 'album')
}
# Add album image if available
if track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
spotify_track_data = {
'id': track_info.get('id'),
'name': track_info.get('name'),
'artists': formatted_artists,
'album': {
'name': track_info.get('album'),
'album_type': track_info.get('album_type', 'album') # Use track's album type if available
},
'album': album_data,
'duration_ms': track_info.get('duration_ms')
}

@ -2612,27 +2612,66 @@
<!-- Metadata Enhancement Settings -->
<div class="settings-group">
<h3>🎵 Metadata Enhancement</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="metadata-enabled" checked>
Enable metadata enhancement with Spotify data
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="embed-album-art" checked>
Embed high-quality album art from Spotify
</label>
</div>
<div class="form-group">
<label>Supported Formats:</label>
<div class="supported-formats">MP3, FLAC, MP4/M4A, OGG</div>
</div>
</div>
<!-- File Organization Settings -->
<div class="settings-group">
<h3>📁 File Organization</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="file-organization-enabled" checked>
Enable custom file organization templates
</label>
</div>
<div class="form-group">
<label>Album Path Template:</label>
<input type="text" id="template-album-path" placeholder="$albumartist/$albumartist - $album/$track - $title">
<small style="color: #888;">Variables: $albumartist, $artist, $album, $title, $track</small>
</div>
<div class="form-group">
<label>Single Path Template:</label>
<input type="text" id="template-single-path" placeholder="$artist/$artist - $title/$title">
<small style="color: #888;">Variables: $artist, $title, $album</small>
</div>
<div class="form-group">
<label>Playlist Path Template:</label>
<input type="text" id="template-playlist-path" placeholder="$playlist/$artist - $title">
<small style="color: #888;">Variables: $playlist, $artist, $title</small>
</div>
<div class="form-group">
<button class="test-button" onclick="resetFileOrganizationTemplates()" style="background: #666;">
🔄 Reset to Defaults
</button>
<small style="color: #888; display: block; margin-top: 8px;">
Restores original path structure. Your downloads will be organized like before.
</small>
</div>
</div>
<!-- Playlist Sync Settings -->
<div class="settings-group">
<h3>Playlist Sync Settings</h3>

@ -1456,6 +1456,123 @@ function initializeSettings() {
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
}
function resetFileOrganizationTemplates() {
// Reset templates to defaults
const defaults = {
album: '$albumartist/$albumartist - $album/$track - $title',
single: '$artist/$artist - $title/$title',
playlist: '$playlist/$artist - $title'
};
document.getElementById('template-album-path').value = defaults.album;
document.getElementById('template-single-path').value = defaults.single;
document.getElementById('template-playlist-path').value = defaults.playlist;
showToast('Templates reset to defaults. Click "Save Settings" to apply.', 'success');
}
function validateFileOrganizationTemplates() {
const errors = [];
// Valid variables for each template type
const validVars = {
album: ['$artist', '$albumartist', '$album', '$title', '$track'],
single: ['$artist', '$albumartist', '$album', '$title'],
playlist: ['$artist', '$playlist', '$title']
};
// Get template values
const albumPath = document.getElementById('template-album-path').value.trim();
const singlePath = document.getElementById('template-single-path').value.trim();
const playlistPath = document.getElementById('template-playlist-path').value.trim();
// Validate album template
if (albumPath) {
if (albumPath.endsWith('/')) {
errors.push('Album template cannot end with /');
}
if (albumPath.startsWith('/')) {
errors.push('Album template cannot start with /');
}
if (!albumPath.includes('/')) {
errors.push('Album template must include at least one folder (use / separator)');
}
if (albumPath.includes('//')) {
errors.push('Album template cannot have consecutive slashes //');
}
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
const albumVarPattern = /\$[a-zA-Z]+/g;
const foundVars = albumPath.match(albumVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
// Check if lowercase version exists in valid vars
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`);
} else if (v !== lowerVar && validVars.album.includes(lowerVar)) {
// Variable is valid but has wrong case
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate single template
if (singlePath) {
if (singlePath.endsWith('/')) {
errors.push('Single template cannot end with /');
}
if (singlePath.startsWith('/')) {
errors.push('Single template cannot start with /');
}
if (!singlePath.includes('/')) {
errors.push('Single template must include at least one folder (use / separator)');
}
if (singlePath.includes('//')) {
errors.push('Single template cannot have consecutive slashes //');
}
const singleVarPattern = /\$[a-zA-Z]+/g;
const foundVars = singlePath.match(singleVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`);
} else if (v !== lowerVar && validVars.single.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
// Validate playlist template
if (playlistPath) {
if (playlistPath.endsWith('/')) {
errors.push('Playlist template cannot end with /');
}
if (playlistPath.startsWith('/')) {
errors.push('Playlist template cannot start with /');
}
if (!playlistPath.includes('/')) {
errors.push('Playlist template must include at least one folder (use / separator)');
}
if (playlistPath.includes('//')) {
errors.push('Playlist template cannot have consecutive slashes //');
}
const playlistVarPattern = /\$[a-zA-Z]+/g;
const foundVars = playlistPath.match(playlistVarPattern) || [];
foundVars.forEach(v => {
const lowerVar = v.toLowerCase();
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
} else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) {
errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
}
});
}
return errors;
}
async function loadSettingsData() {
try {
const response = await fetch(API.settings);
@ -1526,7 +1643,13 @@ async function loadSettingsData() {
// Populate Metadata Enhancement settings
document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false;
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
// Populate File Organization settings
document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false;
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';
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
@ -1837,6 +1960,13 @@ async function saveQualityProfile() {
// ===============================
async function saveSettings() {
// Validate file organization templates before saving
const validationErrors = validateFileOrganizationTemplates();
if (validationErrors.length > 0) {
showToast('Template validation failed: ' + validationErrors.join(', '), 'error');
return;
}
// Determine active server from toggle buttons
let activeServer = 'plex';
if (document.getElementById('jellyfin-toggle').classList.contains('active')) {
@ -1844,7 +1974,7 @@ async function saveSettings() {
} else if (document.getElementById('navidrome-toggle').classList.contains('active')) {
activeServer = 'navidrome';
}
const settings = {
active_media_server: activeServer,
spotify: {
@ -1886,6 +2016,14 @@ async function saveSettings() {
enabled: document.getElementById('metadata-enabled').checked,
embed_album_art: document.getElementById('embed-album-art').checked
},
file_organization: {
enabled: document.getElementById('file-organization-enabled').checked,
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_sync: {
create_backup: document.getElementById('create-backup').checked
}
@ -4272,7 +4410,7 @@ async function openDownloadMissingModal(playlistId) {
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
@ -4633,7 +4771,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
@ -12549,7 +12687,7 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
@ -19451,7 +19589,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${formatArtists(track.artists)}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>

Loading…
Cancel
Save