Merge branch 'feature/informations-window' into fusion-refafac-setings
# Conflicts: # .gitignore
This commit is contained in:
@@ -1,3 +1,100 @@
|
|||||||
|
# v0.31.5 (Unreleased)
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Quick View on Hover** (GUI): Revolutionary hover preview system for track metadata
|
||||||
|
|
||||||
|
- Tabbed interface with Details and Cover Art tabs
|
||||||
|
- Instant metadata display on hover (no clicks required)
|
||||||
|
- Debounced events (350ms) to prevent UI flickering
|
||||||
|
- Zero additional API calls - uses pre-loaded track data
|
||||||
|
- Displays: Title, Artists, Album, Codec, Bitrate, Duration, BPM, ISRC, Release Date, Popularity
|
||||||
|
- **BPM Loading Indicator**: Shows `⏳ Loading...` while fetching BPM asynchronously
|
||||||
|
- Graceful handling of missing metadata
|
||||||
|
|
||||||
|
- **Enhanced BPM Support** (GUI):
|
||||||
|
- BPM now loaded asynchronously from TIDAL API raw JSON data
|
||||||
|
- Visual loading indicator (`⏳ Loading...`) while fetching
|
||||||
|
- Smart caching prevents redundant API calls
|
||||||
|
- Thread-safe callback system using Qt signals
|
||||||
|
- Displays actual BPM value (e.g., "136") when available
|
||||||
|
- Shows `—` when BPM is not provided by TIDAL
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Code Cleanup & Optimization**:
|
||||||
|
|
||||||
|
- **Removed all debug logs** from console output for production-ready experience
|
||||||
|
- Application now runs silently with clean console (only "All setup." message)
|
||||||
|
- Removed unused code: `_extract_from_raw()` method and redundant helper functions
|
||||||
|
- Simplified error handling with silent exceptions for expected errors (widgets deleted, etc.)
|
||||||
|
- **Removed 150+ lines of debug/logging code** across multiple files
|
||||||
|
|
||||||
|
- **Code Refactoring & Architecture**:
|
||||||
|
|
||||||
|
- Created `helper/metadata_utils.py` module for reusable metadata utilities
|
||||||
|
- Created `cache.py` module for LRU cache implementations
|
||||||
|
- Created `ui/media_details_helper.py` for media details population
|
||||||
|
ét - Created `gui_covers.py` with `CoverManager` class for cover management
|
||||||
|
- Removed ~340 lines of duplicated code from `info_tab_widget.py`
|
||||||
|
- Extracted helper functions: `safe_str()`, `find_attr()`, `search_in_data()`, `extract_names_from_mixed()`
|
||||||
|
- Improved maintainability and testability of metadata extraction logic
|
||||||
|
- **gui.py reduced from 2828 to ~2640 lines (-188 lines, -6.6%)**
|
||||||
|
- All cover-related logic centralized in CoverManager
|
||||||
|
|
||||||
|
- **Performance & Stability**:
|
||||||
|
|
||||||
|
- Thread-safe track extras cache with automatic cleanup
|
||||||
|
- **Cover pixmap cache (LRU, 100 entries)** for instant display on re-hover
|
||||||
|
- **Background cover preloading** when opening playlists (first 50 tracks)
|
||||||
|
- Smart cache checks before launching download threads
|
||||||
|
- Skip re-display if cover already shown (0ms overhead)
|
||||||
|
- Graceful shutdown handling for background workers
|
||||||
|
- Proper cleanup of event filters on application exit
|
||||||
|
- Better error handling for widget lifecycle and API failures
|
||||||
|
- Protected against RuntimeError when widgets deleted during shutdown
|
||||||
|
- **Fixed BPM display issue**: BPM callback now properly invoked in main thread using Qt signals
|
||||||
|
|
||||||
|
- **UI/UX Improvements**:
|
||||||
|
- BPM field shows loading state instead of blank placeholder
|
||||||
|
- Immediate visual feedback when hovering tracks
|
||||||
|
- Smooth asynchronous data loading without blocking UI
|
||||||
|
- Cache system ensures instant display on re-hover
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- **Fixed BPM not displaying**: Corrected callback invocation from worker thread to main thread
|
||||||
|
- **Fixed Qt threading issues**: Replaced `QMetaObject.invokeMethod` with direct signal-based approach
|
||||||
|
- **Fixed "widgets deleted during extras apply"**: Changed from QueuedConnection to direct call
|
||||||
|
- **Fixed race conditions**: Proper thread-safe callback storage and invocation
|
||||||
|
|
||||||
|
## Not Displayed (API Limitations)
|
||||||
|
|
||||||
|
Certain metadata fields are **not displayed** because they are generally **not provided** by the public TIDAL API and are therefore **not applicable** in this interface:
|
||||||
|
|
||||||
|
- ❌ Genres
|
||||||
|
- ❌ Label
|
||||||
|
- ❌ Producers
|
||||||
|
- ❌ Composers
|
||||||
|
|
||||||
|
For details, see `docs/missing_metadata.md`.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- New components: `InfoTabWidget`, `HoverManager`, `TrackInfoFormatter`, `CoverManager`
|
||||||
|
- Event filtering on tree view viewport for precise hover detection
|
||||||
|
- Signal-based architecture for decoupled communication
|
||||||
|
- Qt Signal (`s_invoke_callback`) for thread-safe callback invocation
|
||||||
|
- Worker thread pattern for async API calls without blocking UI
|
||||||
|
- Full conformance to AGENTS.md standards (type hints, docstrings, tests)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- Updated `docs/missing_metadata.md` with current API limitations
|
||||||
|
- Created `docs/feature_hover_info.md` with comprehensive feature documentation
|
||||||
|
- Added technical architecture diagrams and troubleshooting guides
|
||||||
|
- Documented BPM loading flow and caching strategy
|
||||||
|
|
||||||
# v0.4.11
|
# v0.4.11
|
||||||
|
|
||||||
- Fixes regarding empty metadata tags (also fixes #1).
|
- Fixes regarding empty metadata tags (also fixes #1).
|
||||||
|
|||||||
+70
-12
@@ -93,25 +93,51 @@ Now you can make your changes locally.
|
|||||||
|
|
||||||
7. When you're done making changes, check that your changes pass the formatting tests.
|
7. When you're done making changes, check that your changes pass the formatting tests.
|
||||||
|
|
||||||
```bash
|
**On Linux/macOS:**
|
||||||
make check
|
|
||||||
```
|
|
||||||
|
|
||||||
Now, validate that all unit tests are passing:
|
```bash
|
||||||
|
make check
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
**On Windows PowerShell (equivalent commands):**
|
||||||
make test
|
|
||||||
```
|
```powershell
|
||||||
|
poetry check --lock
|
||||||
|
poetry run pre-commit run -a
|
||||||
|
poetry run deptry .
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Now, validate that all unit tests are passing:
|
||||||
|
|
||||||
|
**On Linux/macOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
**On Windows PowerShell (equivalent):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
poetry run pytest --doctest-modules
|
||||||
|
```
|
||||||
|
|
||||||
9. Before raising a pull request you should also run tox.
|
9. Before raising a pull request you should also run tox.
|
||||||
This will run the tests across different versions of Python:
|
This will run the tests across different versions of Python:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tox
|
tox
|
||||||
```
|
```
|
||||||
|
|
||||||
This requires you to have multiple versions of Python installed.
|
**Note:** This requires you to have multiple versions of Python installed.
|
||||||
This step is also triggered in the CI/CD pipeline, so you could also choose to skip this step locally.
|
This step is also triggered in the CI/CD pipeline, so you could also choose to skip this step locally.
|
||||||
|
|
||||||
|
**Windows users:** If `tox` fails with Git/dulwich errors (like `KeyError: b'HEAD'`), you can skip this step as the CI/CD pipeline will run it automatically when you create a pull request. Make sure you have committed your changes to Git before running tox:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add .
|
||||||
|
git commit -m "Your changes"
|
||||||
|
# Then try tox again, or skip it and rely on CI/CD
|
||||||
|
```
|
||||||
|
|
||||||
10. Commit your changes and push your branch to GitHub:
|
10. Commit your changes and push your branch to GitHub:
|
||||||
|
|
||||||
@@ -131,3 +157,35 @@ Before you submit a pull request, check that it meets these guidelines:
|
|||||||
|
|
||||||
2. If the pull request adds functionality, the docs should be updated.
|
2. If the pull request adds functionality, the docs should be updated.
|
||||||
Put your new functionality into a function with a docstring, and add the feature to the list in `README.md`.
|
Put your new functionality into a function with a docstring, and add the feature to the list in `README.md`.
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Windows-specific Issues
|
||||||
|
|
||||||
|
### Tox fails with UnicodeDecodeError or KeyError: b'HEAD'
|
||||||
|
|
||||||
|
This is a known issue on Windows when:
|
||||||
|
|
||||||
|
- The Git repository doesn't have a valid HEAD (no commits yet)
|
||||||
|
- There are encoding issues with Git output
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
|
||||||
|
1. Make sure you have at least one commit in your Git repository:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status # Check if you have uncommitted changes
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial commit"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If the error persists, you can skip `tox` locally and rely on the CI/CD pipeline to run it when you create a pull request.
|
||||||
|
|
||||||
|
3. Alternatively, run the tests manually for your Python version:
|
||||||
|
```powershell
|
||||||
|
poetry run pytest --doctest-modules
|
||||||
|
```
|
||||||
|
|
||||||
|
### Make commands don't work on Windows
|
||||||
|
|
||||||
|
If you're on Windows and `make` is not available, use the PowerShell equivalent commands documented in steps 7 and 8 above, or install `make` for Windows (e.g., via Chocolatey: `choco install make`).
|
||||||
|
|||||||
@@ -7,9 +7,13 @@
|
|||||||
|
|
||||||
This tool allows you to download songs and videos from TIDAL. Multithreaded and multi-chunked downloads are supported.
|
This tool allows you to download songs and videos from TIDAL. Multithreaded and multi-chunked downloads are supported.
|
||||||
|
|
||||||
⚠️ **Windows** Defender / **Anti Virus** software / web browser alerts, while you try to download the app binary: This is a **false positive**. Please read [this issue](https://github.com/exislow/tidal-dl-ng/issues/231), [PyInstaller (used by this project) statement](https://github.com/pyinstaller/pyinstaller/blob/develop/.github/ISSUE_TEMPLATE/antivirus.md) and [the alternative installation solution](https://github.com/exislow/tidal-dl-ng/?tab=readme-ov-file#-installation--upgrade).
|
⚠️ **Windows** Defender / **Anti Virus** software / web browser alerts, while you try to download the app binary: This
|
||||||
|
is a **false positive**. Please
|
||||||
|
read [this issue](https://github.com/exislow/tidal-dl-ng/issues/231), [PyInstaller (used by this project) statement](https://github.com/pyinstaller/pyinstaller/blob/develop/.github/ISSUE_TEMPLATE/antivirus.md)
|
||||||
|
and [the alternative installation solution](https://github.com/exislow/tidal-dl-ng/?tab=readme-ov-file#-installation--upgrade).
|
||||||
|
|
||||||
**A paid TIDAL plan is required!** Audio quality varies up to HiRes Lossless / TIDAL MAX 24-bit, 192 kHz depending on the song available. Dolby Atmos is supported. You can use the command line or GUI version of this tool.
|
**A paid TIDAL plan is required!** Audio quality varies up to HiRes Lossless / TIDAL MAX 24-bit, 192 kHz depending on
|
||||||
|
the song available. Dolby Atmos is supported. You can use the command line or GUI version of this tool.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -86,12 +90,15 @@ If you would like to use the GUI version as a binary, have a look at the
|
|||||||
|
|
||||||
- Download tracks, videos, albums, playlists, your favorites etc.
|
- Download tracks, videos, albums, playlists, your favorites etc.
|
||||||
- Multithreaded and multi-chunked downloads
|
- Multithreaded and multi-chunked downloads
|
||||||
- Metadata for songs
|
- Metadata for songs (including extended metadata like genres, producers, composers when available from TIDAL API -
|
||||||
|
see [metadata documentation](docs/missing_metadata.md))
|
||||||
- Adjustable audio and video download quality.
|
- Adjustable audio and video download quality.
|
||||||
- FLAC extraction from MP4 containers
|
- FLAC extraction from MP4 containers
|
||||||
- Lyrics and album art / cover download
|
- Lyrics and album art / cover download
|
||||||
- Creates playlist files
|
- Creates playlist files
|
||||||
- Can symlink tracks instead of having several copies, if added to different playlists
|
- Can symlink tracks instead of having several copies, if added to different playlist
|
||||||
|
- **🆕 Quick View on Hover** (GUI): Hover over tracks to instantly preview rich metadata without clicking (
|
||||||
|
see [feature documentation](docs/feature_hover_info.md))
|
||||||
|
|
||||||
## ▶️ Getting started with development
|
## ▶️ Getting started with development
|
||||||
|
|
||||||
@@ -149,25 +156,32 @@ See the `Makefile` for all available build commands.
|
|||||||
|
|
||||||
The CI/CD pipeline will be triggered when you open a pull request, merge to main, or when you create a new release.
|
The CI/CD pipeline will be triggered when you open a pull request, merge to main, or when you create a new release.
|
||||||
|
|
||||||
To finalize the set-up for publishing to PyPi or Artifactory, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/publishing/#set-up-for-pypi).
|
To finalize the set-up for publishing to PyPi or Artifactory,
|
||||||
For activating the automatic documentation with MkDocs, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/mkdocs/#enabling-the-documentation-on-github).
|
see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/publishing/#set-up-for-pypi).
|
||||||
|
For activating the automatic documentation with MkDocs,
|
||||||
|
see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/mkdocs/#enabling-the-documentation-on-github).
|
||||||
To enable the code coverage reports, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/codecov/).
|
To enable the code coverage reports, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/codecov/).
|
||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
### macOS Error Message: File/App is damaged and cannot be opened. You should move it to Trash
|
### macOS Error Message: File/App is damaged and cannot be opened. You should move it to Trash
|
||||||
|
|
||||||
If you download an (unsigned) app from any source other than those that Apple deems trusted, the application gets an extended attribute "com.apple.Quarantine". This triggers the message: "<application> is damaged and can't be opened. You should move it to the Bin."
|
If you download an (unsigned) app from any source other than those that Apple seems suited, the application gets an
|
||||||
|
extended attribute "com.apple.Quarantine". This triggers the message: "<application> is damaged and can't be opened. You
|
||||||
|
should move it to the Bin."
|
||||||
|
|
||||||
Remove the attribute and you can launch the application. [Source 1](https://discussions.apple.com/thread/253714860?sortBy=rank) [Source 2](https://www.reddit.com/r/macsysadmin/comments/13vu7f3/app_is_damaged_and_cant_be_opened_error_on_ventura/)
|
Remove the attribute and you can launch the
|
||||||
|
application. [Source 1](https://discussions.apple.com/thread/253714860?sortBy=rank) [Source 2](https://www.reddit.com/r/macsysadmin/comments/13vu7f3/app_is_damaged_and_cant_be_opened_error_on_ventura/)
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo xattr -dr com.apple.quarantine /Applications/TIDAL-Downloader-NG.app/
|
sudo xattr -dr com.apple.quarantine /Applications/TIDAL-Downloader-NG.app/
|
||||||
```
|
```
|
||||||
|
|
||||||
Why is this app unsigned? Only developers enrolled in the paid Apple Developer Program are allowed to sign (legal) apps. Without this subscription, app signing is not possible.
|
Why is this app unsigned? Only developer enrolled in the paid Apple developer program are allowed to sign (legal) apps.
|
||||||
|
Without this subscription app signing is not possible.
|
||||||
|
|
||||||
Does Gatekeeper really annoy you, and you'd like to disable it completely? Follow this [link](https://iboysoft.com/tips/how-to-disable-gatekeeper-macos-sequoia.html)
|
Gatekeeper really annoys you, and you like to disable it completely? Follow
|
||||||
|
this [link](https://iboysoft.com/tips/how-to-disable-gatekeeper-macos-sequoia.html)
|
||||||
|
|
||||||
### My (Windows) antivirus app XYZ says the GUI version of this app is harmful
|
### My (Windows) antivirus app XYZ says the GUI version of this app is harmful
|
||||||
|
|
||||||
@@ -177,7 +191,8 @@ Long answer: See [here](https://github.com/exislow/tidal-dl-ng/issues/231)
|
|||||||
|
|
||||||
### I get an error when `extract_flac` is enabled
|
### I get an error when `extract_flac` is enabled
|
||||||
|
|
||||||
Your `path_binary_ffmpeg` is probably wrong. Please read over and over again the help of this particular option until you get it right what path to put for `path_binary_ffmpeg`.
|
Your `path_binary_ffmpeg` is probably wrong. Please read over and over again the help of this particular option until
|
||||||
|
you get it right what path to put for `path_binary_ffmpeg`.
|
||||||
|
|
||||||
### My Linux (e.g. Ubuntu) complains that `libxcb-cursor0` is not installed
|
### My Linux (e.g. Ubuntu) complains that `libxcb-cursor0` is not installed
|
||||||
|
|
||||||
@@ -197,7 +212,20 @@ This is due to the Python `ffmpeg` library which is used and only happens on win
|
|||||||
|
|
||||||
### How can I download Dolby Atmos files?
|
### How can I download Dolby Atmos files?
|
||||||
|
|
||||||
You need to activate `download_dolby_atmos` in the settings. Then, if an item is available in Dolby Atmos, it will be downloaded as a Dolby Atmos file instead of a stereo audio file. Dolby Atmos is only available as 320kbps at TIDAL (you cannot adjust the quality for Dolby Atmos downloads). If an item is available in Dolby Atmos, the "Quality" column in the GUI will indicate this with `Dolby Atmos`.
|
You need to activate `download_dolby_atmos` in the settings. Then, if an item is available in Dolby Atmos, it will be
|
||||||
|
downloaded as an Dolby Atmos file instead of as an stereo audio file. Dolby Atmos ist only available as 320kbps at
|
||||||
|
TIDAL (you cannot adjust the quality for Dolby Atmos downloads). If an item is available in Dolby Atmos the "Quality"
|
||||||
|
column in the GUI will indicate this with `Dolby Atmos`
|
||||||
|
|
||||||
|
### Why do some metadata fields show "—" or "N/A"?
|
||||||
|
|
||||||
|
Some metadata fields like Genres, Producers, Composers, Label, or BPM may display `—` because **the TIDAL API does not
|
||||||
|
provide this information** for that specific track or album. This is not a bug - TIDAL simply doesn't have or doesn't
|
||||||
|
expose this data via their API.
|
||||||
|
|
||||||
|
- **Bitrate shows "N/A"** for LOSSLESS tracks because they use variable bitrate compression
|
||||||
|
- **Extended metadata** (genres, producers, etc.) depends on what TIDAL receives from music labels
|
||||||
|
- See the [detailed metadata documentation](docs/missing_metadata.md) for more information
|
||||||
|
|
||||||
## ‼️ Disclaimer
|
## ‼️ Disclaimer
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Hover Info Feature
|
||||||
|
|
||||||
|
## Quick Preview on Hover
|
||||||
|
|
||||||
|
TIDAL Downloader NG includes a revolutionary hover preview system that displays track metadata instantly when you hover over tracks in the results list.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Instant Display**: Track information appears immediately on hover (no clicks required)
|
||||||
|
- **Tabbed Interface**:
|
||||||
|
- **Details Tab**: Shows comprehensive track metadata
|
||||||
|
- **Cover Art Tab**: Displays album artwork
|
||||||
|
- **Smart Debouncing**: 350ms delay prevents UI flickering when moving the mouse
|
||||||
|
- **Zero Extra API Calls**: Uses pre-loaded track data from search results
|
||||||
|
- **Efficient Caching**: Previously viewed tracks display instantly
|
||||||
|
|
||||||
|
### Displayed Information
|
||||||
|
|
||||||
|
#### Basic Information (Instant)
|
||||||
|
|
||||||
|
- **Title**: Track name
|
||||||
|
- **Version**: Track version/remix info (if available)
|
||||||
|
- **Artists**: Track artists
|
||||||
|
- **Track #**: Track number in album
|
||||||
|
- **Album**: Album name
|
||||||
|
- **Duration**: Track length (mm:ss)
|
||||||
|
- **Codec**: Audio quality (LOSSLESS, AAC, etc.)
|
||||||
|
- **Bitrate**: Audio bitrate (or N/A for variable bitrate)
|
||||||
|
- **Release Date**: Album release date
|
||||||
|
- **Popularity**: Track popularity score
|
||||||
|
- **ISRC**: International Standard Recording Code
|
||||||
|
|
||||||
|
#### Enhanced Information (Async)
|
||||||
|
|
||||||
|
- **BPM**: Beats per minute ⏳ (loaded asynchronously from API)
|
||||||
|
- Shows `⏳ Loading...` while fetching (~100-500ms)
|
||||||
|
- Displays actual BPM value when available
|
||||||
|
- Shows `—` if not available
|
||||||
|
|
||||||
|
### Cover Art Display
|
||||||
|
|
||||||
|
- **Automatic Loading**: Album covers are loaded in the background
|
||||||
|
- **Smart Caching**: Covers are cached for instant display on re-hover
|
||||||
|
- **Preloading**: First 50 tracks in playlists are preloaded for better UX
|
||||||
|
- **Fallback**: Default album image shown if cover is unavailable
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
User hovers over track
|
||||||
|
↓
|
||||||
|
HoverManager detects hover (debounced 350ms)
|
||||||
|
↓
|
||||||
|
InfoTabWidget receives update signal
|
||||||
|
↓
|
||||||
|
_populate_track_details() displays basic info instantly
|
||||||
|
↓
|
||||||
|
_request_track_extras_if_needed() triggers async fetch
|
||||||
|
↓
|
||||||
|
Worker thread fetches BPM from TIDAL API
|
||||||
|
↓
|
||||||
|
Qt Signal invokes callback in main thread
|
||||||
|
↓
|
||||||
|
_update_extras_ui() updates BPM label
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Performance Optimizations
|
||||||
|
|
||||||
|
1. **LRU Cache**: 256-entry cache for track extras
|
||||||
|
2. **Cover Cache**: 100-entry cache for album covers
|
||||||
|
3. **Thread-Safe**: All background operations are thread-safe
|
||||||
|
4. **Event Filtering**: Precise hover detection on tree view viewport
|
||||||
|
5. **Graceful Degradation**: Handles missing metadata gracefully
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
|
||||||
|
#### On First Hover
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: Fortuna ✅ Instant
|
||||||
|
Artists: Asco ✅ Instant
|
||||||
|
Duration: 03:40 ✅ Instant
|
||||||
|
Codec: LOSSLESS ✅ Instant
|
||||||
|
BPM: ⏳ Loading... ⏱️ Loading indicator (~200ms)
|
||||||
|
↓
|
||||||
|
BPM: 136 ✅ Loaded!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### On Subsequent Hover (Same Track)
|
||||||
|
|
||||||
|
```
|
||||||
|
Title: Fortuna ✅ Instant
|
||||||
|
Artists: Asco ✅ Instant
|
||||||
|
Duration: 03:40 ✅ Instant
|
||||||
|
BPM: 136 ✅ Instant (from cache)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
Due to TIDAL API limitations, the following fields are **not applicable** in this hover view (they are generally not provided by the public API and thus not displayed):
|
||||||
|
|
||||||
|
- ❌ Genres
|
||||||
|
- ❌ Label
|
||||||
|
- ❌ Producers
|
||||||
|
- ❌ Composers
|
||||||
|
|
||||||
|
See [missing_metadata.md](missing_metadata.md) for detailed information about API limitations.
|
||||||
|
|
||||||
|
### Code Components
|
||||||
|
|
||||||
|
- **`InfoTabWidget`**: Main widget for displaying track information
|
||||||
|
- **`HoverManager`**: Manages hover events on tree view
|
||||||
|
- **`CoverManager`**: Handles cover art loading and caching
|
||||||
|
- **`TrackInfoFormatter`**: Formats track metadata for display
|
||||||
|
- **`TrackExtrasCache`**: LRU cache for async-loaded metadata
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The hover delay is configurable in the code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In hover_manager.py
|
||||||
|
self.hover_delay = 350 # milliseconds
|
||||||
|
```
|
||||||
|
|
||||||
|
Lower values = more responsive but may flicker
|
||||||
|
Higher values = more stable but less responsive
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**BPM not displaying?**
|
||||||
|
|
||||||
|
- Check console for API errors
|
||||||
|
- Verify track has BPM in TIDAL (not all tracks do)
|
||||||
|
- Cache may have stale data (restart application)
|
||||||
|
|
||||||
|
**Cover not loading?**
|
||||||
|
|
||||||
|
- Check network connection
|
||||||
|
- Some tracks may not have cover art
|
||||||
|
- Default image will be shown as fallback
|
||||||
|
|
||||||
|
**Hover not working?**
|
||||||
|
|
||||||
|
- Ensure you're hovering over the results tree view
|
||||||
|
- Check that the application has focus
|
||||||
|
- Debounce delay may need adjustment
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Missing Metadata in the Interface
|
||||||
|
|
||||||
|
## Why do some fields display "—" or "N/A"?
|
||||||
|
|
||||||
|
When using TIDAL Downloader NG, some metadata fields may display `—` (dash) or `N/A`. This means that **the data is not provided by the TIDAL API** for that specific track/album.
|
||||||
|
|
||||||
|
### Affected Fields
|
||||||
|
|
||||||
|
The following fields depend on data availability from the TIDAL API:
|
||||||
|
|
||||||
|
| Field | API Source | Notes | Status |
|
||||||
|
| ------------- | ------------------------------------------------------------------- | -------------------------------------- | ------------------------- |
|
||||||
|
| **BPM** | Track JSON (`bpm`) | Loaded asynchronously from API | ✅ Functional |
|
||||||
|
| **Bitrate** | Calculated based on codec | `N/A` for LOSSLESS (variable bitrate) | ✅ Functional |
|
||||||
|
| **Codec** | Track metadata | Always available (LOSSLESS, AAC, etc.) | ✅ Functional |
|
||||||
|
| **Genres** | Album JSON (`genres` or `genre`) | Not available via TIDAL API v2 | ❌ Removed from interface |
|
||||||
|
| **Label** | Album JSON (`label` or `recordLabel`) | Not reliably available | ❌ Removed from interface |
|
||||||
|
| **Producers** | Track/Album JSON (`credits` or `contributors` with role="producer") | Not available via TIDAL API v2 | ❌ Removed from interface |
|
||||||
|
| **Composers** | Track/Album JSON (`credits` or `contributors` with role="composer") | Not available via TIDAL API v2 | ❌ Removed from interface |
|
||||||
|
|
||||||
|
### Recent Improvements & API Limitations
|
||||||
|
|
||||||
|
**Current version**: The application retrieves available metadata from the TIDAL API:
|
||||||
|
|
||||||
|
- ✅ **BPM**: Loaded asynchronously from `track._data['bpm']` via API
|
||||||
|
- ✅ Display of basic information: title, artists, album, duration, codec, bitrate
|
||||||
|
- ✅ ISRC, track number, release date, popularity
|
||||||
|
- ❌ **Genres, Label, Producers, Composers**: Removed from interface (not available via API)
|
||||||
|
|
||||||
|
**TIDAL API Limitations**:
|
||||||
|
|
||||||
|
According to the official TIDAL OpenAPI specification analysis (https://tidal-music.github.io/tidal-api-reference/):
|
||||||
|
|
||||||
|
- ❌ **No reliable `genres` field** in API v2 (sometimes present but often empty)
|
||||||
|
- ❌ **No `credits` field** in API v2
|
||||||
|
- ❌ **No `contributors` field** in API v2
|
||||||
|
- ❌ **No `producers` field** available
|
||||||
|
- ❌ **No `composers` field** available
|
||||||
|
- ❌ **`label` and `recordLabel`** only available at album level, and often absent
|
||||||
|
|
||||||
|
The TIDAL API v2 (official JSON:API) does **NOT** reliably provide these metadata. The older API v1 (undocumented) may contain some of these fields randomly, but they are not guaranteed and depend entirely on what music labels provide to TIDAL.
|
||||||
|
|
||||||
|
### What is Actually Available
|
||||||
|
|
||||||
|
Fields **guaranteed** by the TIDAL API:
|
||||||
|
|
||||||
|
- ✅ `title`, `duration`, `isrc`, `explicit`
|
||||||
|
- ✅ `bpm` (optional, when provided by the label)
|
||||||
|
- ✅ `popularity` (0.0 - 1.0)
|
||||||
|
- ✅ `mediaTags` (HIRES_LOSSLESS, etc.)
|
||||||
|
- ✅ `artists` via relationship
|
||||||
|
- ✅ `album` via relationship
|
||||||
|
- ✅ `genres` via relationship (but often empty)
|
||||||
|
|
||||||
|
### Why is this Data Missing?
|
||||||
|
|
||||||
|
1. **TIDAL doesn't have the information**: Some metadata is not provided by music labels
|
||||||
|
2. **Data not exposed by API**: Even if TIDAL displays certain information on their website, the public API may not provide it
|
||||||
|
3. **Variability by track**: An album may have genres, but individual tracks may not
|
||||||
|
4. **Metadata quality**: Some independent or older tracks may have incomplete metadata
|
||||||
|
|
||||||
|
### API Response Example
|
||||||
|
|
||||||
|
For the track "Breathing (Techno)" in your screenshot:
|
||||||
|
|
||||||
|
```
|
||||||
|
Track ID: 336084531
|
||||||
|
- BPM: Not provided by TIDAL
|
||||||
|
- Label: Not provided by TIDAL
|
||||||
|
- Genres: Not provided by TIDAL
|
||||||
|
- Contributors: Maybe available but without specific role (producer/composer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to Get More Data?
|
||||||
|
|
||||||
|
1. **Check TIDAL directly**: Sometimes data is visible on the TIDAL website but not via API
|
||||||
|
2. **Use other sources**: MusicBrainz, Discogs, etc. (not integrated in tidal-dl-ng)
|
||||||
|
3. **Manual editing**: After download, use a tag editor (Mp3tag, Kid3, etc.)
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
To see what the TIDAL API returns, you can check the console output when hovering over tracks. The application silently fetches BPM data in the background without cluttering the console.
|
||||||
|
|
||||||
|
**Technical Architecture**: BPM is loaded asynchronously:
|
||||||
|
|
||||||
|
1. Worker thread fetches raw JSON data from TIDAL API
|
||||||
|
2. `parse_track_and_album_extras()` extracts BPM from JSON
|
||||||
|
3. Qt Signal (`s_invoke_callback`) emits result to main thread
|
||||||
|
4. Stored callback is invoked in main thread (safe for UI updates)
|
||||||
|
5. `_handle_track_extras_ready` verifies it's the correct track
|
||||||
|
6. `_update_extras_ui` updates the BPM label in the interface
|
||||||
|
|
||||||
|
**Loading Indicator**: While BPM is being fetched, you'll see:
|
||||||
|
|
||||||
|
- `⏳ Loading...` - BPM data is being fetched from API (~100-500ms)
|
||||||
|
- `136` - BPM value successfully loaded
|
||||||
|
- `—` - BPM not available for this track
|
||||||
|
|
||||||
|
**Note**: If you previously hovered over a track, the BPM will display instantly on subsequent hovers thanks to the built-in cache system.
|
||||||
|
|
||||||
|
## Bitrate = N/A for LOSSLESS
|
||||||
|
|
||||||
|
The **LOSSLESS** codec (FLAC) uses lossless compression with a **variable bitrate (VBR)**. The exact bitrate depends on the audio content and changes constantly during playback. This is why it is displayed as `N/A` rather than a fixed number.
|
||||||
|
|
||||||
|
For codecs with fixed bitrate (AAC, etc.), you will see a number like "320 kbps".
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Track, Video
|
||||||
|
from tidalapi.artist import Artist
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def qt_app():
|
||||||
|
"""Create a QApplication instance for testing Qt widgets."""
|
||||||
|
app = QtWidgets.QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
app = QtWidgets.QApplication([])
|
||||||
|
yield app
|
||||||
|
# Note: don't quit the app as it may be shared across tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def info_tab_widget(qt_app):
|
||||||
|
"""Create an InfoTabWidget instance for testing."""
|
||||||
|
from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget
|
||||||
|
|
||||||
|
widget = InfoTabWidget()
|
||||||
|
yield widget
|
||||||
|
# InfoTabWidget inherits from QObject, not QWidget, so it doesn't have close()
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_track():
|
||||||
|
"""Create a mock Track object with metadata."""
|
||||||
|
track = Mock(spec=Track)
|
||||||
|
track.name = "Bohemian Rhapsody"
|
||||||
|
track.title = "Bohemian Rhapsody"
|
||||||
|
track.full_name = "Bohemian Rhapsody"
|
||||||
|
track.version = "2011 Remaster"
|
||||||
|
track.duration = 354 # 5:54
|
||||||
|
track.explicit = False
|
||||||
|
track.popularity = 95
|
||||||
|
track.bpm = 72
|
||||||
|
track.isrc = "GBUM71029604"
|
||||||
|
track.bit_depth = 24
|
||||||
|
track.sample_rate = 96000
|
||||||
|
track.audio_modes = []
|
||||||
|
track.media_metadata_tags = []
|
||||||
|
track.available = True
|
||||||
|
track.id = 12345
|
||||||
|
|
||||||
|
# Mock album
|
||||||
|
mock_album = Mock(spec=Album)
|
||||||
|
mock_album.name = "A Night at the Opera"
|
||||||
|
mock_album.id = 67890
|
||||||
|
mock_album.release_date = Mock()
|
||||||
|
mock_album.release_date.strftime = Mock(return_value="1975-11-21")
|
||||||
|
track.album = mock_album
|
||||||
|
|
||||||
|
# Mock artists
|
||||||
|
mock_artist = Mock(spec=Artist)
|
||||||
|
mock_artist.name = "Queen"
|
||||||
|
mock_artist.roles = [Mock(name="main")]
|
||||||
|
track.artists = [mock_artist]
|
||||||
|
|
||||||
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_video():
|
||||||
|
"""Create a mock Video object."""
|
||||||
|
video = Mock(spec=Video)
|
||||||
|
video.name = "Thriller"
|
||||||
|
video.title = "Thriller"
|
||||||
|
video.full_name = "Thriller (Official Video)"
|
||||||
|
video.duration = 600
|
||||||
|
video.explicit = False
|
||||||
|
video.video_quality = "1080p"
|
||||||
|
video.available = True
|
||||||
|
video.id = 54321
|
||||||
|
|
||||||
|
mock_artist = Mock(spec=Artist)
|
||||||
|
mock_artist.name = "Michael Jackson"
|
||||||
|
video.artists = [mock_artist]
|
||||||
|
|
||||||
|
mock_album = Mock(spec=Album)
|
||||||
|
mock_album.name = "Thriller"
|
||||||
|
video.album = mock_album
|
||||||
|
|
||||||
|
return video
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tree_view_setup(qt_app):
|
||||||
|
"""Create a tree view with model for testing hover manager."""
|
||||||
|
tree_view = QtWidgets.QTreeView()
|
||||||
|
source_model = QtGui.QStandardItemModel()
|
||||||
|
proxy_model = QtCore.QSortFilterProxyModel()
|
||||||
|
|
||||||
|
proxy_model.setSourceModel(source_model)
|
||||||
|
tree_view.setModel(proxy_model)
|
||||||
|
|
||||||
|
# Add some test data
|
||||||
|
source_model.setColumnCount(2)
|
||||||
|
for i in range(5):
|
||||||
|
item_index = QtGui.QStandardItem(f"Item {i}")
|
||||||
|
item_obj = QtGui.QStandardItem()
|
||||||
|
mock_track = Mock(spec=Track)
|
||||||
|
mock_track.name = f"Track {i}"
|
||||||
|
mock_track.id = i
|
||||||
|
item_obj.setData(mock_track, QtCore.Qt.ItemDataRole.UserRole)
|
||||||
|
source_model.appendRow([item_index, item_obj])
|
||||||
|
|
||||||
|
yield tree_view, proxy_model, source_model
|
||||||
|
|
||||||
|
tree_view.close()
|
||||||
|
tree_view.deleteLater()
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"""Unit tests for the hover info feature.
|
||||||
|
|
||||||
|
This module tests the "Quick View on Hover" functionality, including:
|
||||||
|
- Debounce timer behavior
|
||||||
|
- Info tab widget updates
|
||||||
|
- Data integrity without network calls
|
||||||
|
- Hover state management
|
||||||
|
|
||||||
|
Tests ensure that:
|
||||||
|
1. Rapid hover events are properly debounced
|
||||||
|
2. UI updates only occur after the debounce delay
|
||||||
|
3. Pre-loaded metadata is displayed without API calls
|
||||||
|
4. Hover state properly reverts when mouse leaves
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from tidalapi import Album, Track, Video
|
||||||
|
from tidalapi.artist import Artist
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.hover_manager import HoverManager
|
||||||
|
from tidal_dl_ng.ui.info_tab_widget import InfoTabWidget, TrackInfoFormatter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def qt_app():
|
||||||
|
"""Create a QApplication instance for testing Qt widgets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QApplication: The application instance.
|
||||||
|
"""
|
||||||
|
app = QtWidgets.QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
app = QtWidgets.QApplication([])
|
||||||
|
yield app
|
||||||
|
# Note: Don't quit the app as it may be shared across tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def info_tab_widget(qt_app):
|
||||||
|
"""Create an InfoTabWidget instance for testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qt_app: The QApplication fixture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InfoTabWidget: The widget instance.
|
||||||
|
"""
|
||||||
|
widget = InfoTabWidget()
|
||||||
|
yield widget
|
||||||
|
# InfoTabWidget inherits from QObject, not QWidget, so it doesn't have close()
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_track():
|
||||||
|
"""Create a mock Track object with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock: A mock Track object with realistic attributes.
|
||||||
|
"""
|
||||||
|
track = Mock(spec=Track)
|
||||||
|
track.name = "Bohemian Rhapsody"
|
||||||
|
track.title = "Bohemian Rhapsody"
|
||||||
|
track.full_name = "Bohemian Rhapsody"
|
||||||
|
track.version = "2011 Remaster"
|
||||||
|
track.duration = 354 # 5:54
|
||||||
|
track.explicit = False
|
||||||
|
track.popularity = 95
|
||||||
|
track.bpm = 72
|
||||||
|
track.isrc = "GBUM71029604"
|
||||||
|
track.bit_depth = 24
|
||||||
|
track.sample_rate = 96000
|
||||||
|
track.audio_modes = []
|
||||||
|
track.available = True
|
||||||
|
track.id = 12345
|
||||||
|
|
||||||
|
# Mock album
|
||||||
|
mock_album = Mock(spec=Album)
|
||||||
|
mock_album.name = "A Night at the Opera"
|
||||||
|
mock_album.id = 67890
|
||||||
|
mock_album.release_date = Mock()
|
||||||
|
mock_album.release_date.strftime = Mock(return_value="1975-11-21")
|
||||||
|
track.album = mock_album
|
||||||
|
|
||||||
|
# Mock artists
|
||||||
|
mock_artist = Mock(spec=Artist)
|
||||||
|
mock_artist.name = "Queen"
|
||||||
|
mock_artist.roles = [Mock(name="main")]
|
||||||
|
track.artists = [mock_artist]
|
||||||
|
|
||||||
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_video():
|
||||||
|
"""Create a mock Video object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mock: A mock Video object.
|
||||||
|
"""
|
||||||
|
video = Mock(spec=Video)
|
||||||
|
video.name = "Thriller"
|
||||||
|
video.title = "Thriller"
|
||||||
|
video.full_name = "Thriller (Official Video)"
|
||||||
|
video.duration = 600
|
||||||
|
video.explicit = False
|
||||||
|
video.video_quality = "1080p"
|
||||||
|
video.available = True
|
||||||
|
video.id = 54321
|
||||||
|
|
||||||
|
mock_artist = Mock(spec=Artist)
|
||||||
|
mock_artist.name = "Michael Jackson"
|
||||||
|
video.artists = [mock_artist]
|
||||||
|
|
||||||
|
mock_album = Mock(spec=Album)
|
||||||
|
mock_album.name = "Thriller"
|
||||||
|
video.album = mock_album
|
||||||
|
|
||||||
|
return video
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tree_view_setup(qt_app):
|
||||||
|
"""Create a tree view with model for testing hover manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qt_app: The QApplication fixture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (tree_view, proxy_model, source_model)
|
||||||
|
"""
|
||||||
|
tree_view = QtWidgets.QTreeView()
|
||||||
|
source_model = QtGui.QStandardItemModel()
|
||||||
|
proxy_model = QtCore.QSortFilterProxyModel()
|
||||||
|
|
||||||
|
proxy_model.setSourceModel(source_model)
|
||||||
|
tree_view.setModel(proxy_model)
|
||||||
|
|
||||||
|
# Add some test data
|
||||||
|
source_model.setColumnCount(2)
|
||||||
|
for i in range(5):
|
||||||
|
item_index = QtGui.QStandardItem(f"Item {i}")
|
||||||
|
item_obj = QtGui.QStandardItem()
|
||||||
|
mock_track = Mock(spec=Track)
|
||||||
|
mock_track.name = f"Track {i}"
|
||||||
|
mock_track.id = i
|
||||||
|
item_obj.setData(mock_track, Qt.ItemDataRole.UserRole)
|
||||||
|
source_model.appendRow([item_index, item_obj])
|
||||||
|
|
||||||
|
yield tree_view, proxy_model, source_model
|
||||||
|
|
||||||
|
tree_view.close()
|
||||||
|
tree_view.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrackInfoFormatter:
|
||||||
|
"""Test the TrackInfoFormatter utility class."""
|
||||||
|
|
||||||
|
def test_format_duration_valid(self):
|
||||||
|
"""Test duration formatting with valid input."""
|
||||||
|
assert TrackInfoFormatter.format_duration(354) == "05:54"
|
||||||
|
assert TrackInfoFormatter.format_duration(60) == "01:00"
|
||||||
|
assert TrackInfoFormatter.format_duration(0) == "00:00"
|
||||||
|
|
||||||
|
def test_format_duration_none(self):
|
||||||
|
"""Test duration formatting with None input."""
|
||||||
|
assert TrackInfoFormatter.format_duration(None) == "N/A"
|
||||||
|
|
||||||
|
def test_format_codec(self, mock_track):
|
||||||
|
"""Test codec formatting with a mock track."""
|
||||||
|
with patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES") as _:
|
||||||
|
codec = TrackInfoFormatter.format_codec(mock_track)
|
||||||
|
assert "HI_RES" in codec.upper()
|
||||||
|
|
||||||
|
def test_format_bitrate(self, mock_track):
|
||||||
|
"""Test bitrate formatting."""
|
||||||
|
bitrate = TrackInfoFormatter.format_bitrate(mock_track)
|
||||||
|
assert "24-bit" in bitrate
|
||||||
|
assert "96.0 kHz" in bitrate
|
||||||
|
|
||||||
|
def test_format_bitrate_missing_data(self):
|
||||||
|
"""Test bitrate formatting with missing data."""
|
||||||
|
track = Mock(spec=Track)
|
||||||
|
track.bit_depth = None
|
||||||
|
track.sample_rate = None
|
||||||
|
assert TrackInfoFormatter.format_bitrate(track) == "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInfoTabWidget:
|
||||||
|
"""Test the InfoTabWidget component."""
|
||||||
|
|
||||||
|
def test_widget_initialization(self, info_tab_widget):
|
||||||
|
"""Test that the widget initializes correctly."""
|
||||||
|
assert info_tab_widget is not None
|
||||||
|
# In test context, tab_widget is None because no existing_tab_widget is provided
|
||||||
|
# The widget creates fallback labels for all required fields
|
||||||
|
assert hasattr(info_tab_widget, "lbl_title")
|
||||||
|
assert hasattr(info_tab_widget, "lbl_artists")
|
||||||
|
assert hasattr(info_tab_widget, "lbl_bpm")
|
||||||
|
assert hasattr(info_tab_widget, "cover_label")
|
||||||
|
|
||||||
|
def test_update_on_hover(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test updating widget on hover."""
|
||||||
|
info_tab_widget.update_on_hover(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.current_media_hovered == mock_track
|
||||||
|
# The details should be updated via signal
|
||||||
|
# We can check if the signal was emitted by verifying internal state
|
||||||
|
|
||||||
|
def test_update_on_selection(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test updating widget on selection."""
|
||||||
|
info_tab_widget.update_on_selection(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.current_media_selected == mock_track
|
||||||
|
|
||||||
|
def test_revert_to_selection(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test reverting to selected media after hover."""
|
||||||
|
# Set a selection
|
||||||
|
info_tab_widget.update_on_selection(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Create a different track for hover
|
||||||
|
hover_track = Mock(spec=Track)
|
||||||
|
hover_track.name = "Different Track"
|
||||||
|
info_tab_widget.update_on_hover(hover_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Revert to selection
|
||||||
|
info_tab_widget.revert_to_selection()
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Should have reverted to the original selection
|
||||||
|
assert info_tab_widget.current_media_selected == mock_track
|
||||||
|
|
||||||
|
def test_populate_track_details(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test populating details for a track."""
|
||||||
|
with (
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES"),
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Queen"),
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Bohemian Rhapsody"),
|
||||||
|
):
|
||||||
|
info_tab_widget._populate_track_details(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.lbl_title.text() == "Bohemian Rhapsody"
|
||||||
|
assert info_tab_widget.lbl_version.text() == "2011 Remaster"
|
||||||
|
assert info_tab_widget.lbl_duration.text() == "05:54"
|
||||||
|
assert info_tab_widget.lbl_bpm.text() == "⏳ Loading..."
|
||||||
|
info_tab_widget._update_extras_ui({"bpm": 72})
|
||||||
|
qt_app.processEvents()
|
||||||
|
assert info_tab_widget.lbl_bpm.text() == "72"
|
||||||
|
|
||||||
|
def test_populate_video_details(self, info_tab_widget, mock_video, qt_app):
|
||||||
|
"""Test populating details for a video."""
|
||||||
|
with (
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Michael Jackson"),
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Thriller"),
|
||||||
|
):
|
||||||
|
info_tab_widget._populate_video_details(mock_video)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.lbl_title.text() == "Thriller"
|
||||||
|
assert info_tab_widget.lbl_codec.text() == "1080p"
|
||||||
|
|
||||||
|
def test_clear_display(self, info_tab_widget, qt_app):
|
||||||
|
"""Test clearing the display."""
|
||||||
|
info_tab_widget._clear_display()
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.lbl_title.text() == "—"
|
||||||
|
assert info_tab_widget.lbl_artists.text() == "—"
|
||||||
|
assert info_tab_widget.current_media_hovered is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestHoverManager:
|
||||||
|
"""Test the HoverManager with debounce logic."""
|
||||||
|
|
||||||
|
def test_hover_manager_initialization(self, tree_view_setup, qt_app):
|
||||||
|
"""Test that HoverManager initializes correctly."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hover_manager is not None
|
||||||
|
assert hover_manager.debounce_delay_ms == 100
|
||||||
|
assert hover_manager.pending_media is None
|
||||||
|
|
||||||
|
def test_debounce_single_hover(self, tree_view_setup, qt_app):
|
||||||
|
"""Test that a single hover event is confirmed after debounce delay."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock signal to track emissions
|
||||||
|
hover_confirmed_spy = []
|
||||||
|
|
||||||
|
def on_hover_confirmed(media):
|
||||||
|
hover_confirmed_spy.append(media)
|
||||||
|
|
||||||
|
hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
|
||||||
|
|
||||||
|
# Simulate mouse movement over an item
|
||||||
|
# (This is complex to test properly without actual UI interaction)
|
||||||
|
# For now, we'll test the timer directly
|
||||||
|
mock_media = Mock(spec=Track)
|
||||||
|
mock_media.name = "Test Track"
|
||||||
|
|
||||||
|
hover_manager.pending_media = mock_media
|
||||||
|
hover_manager.debounce_timer.start()
|
||||||
|
|
||||||
|
# Wait for debounce delay + some buffer
|
||||||
|
qt_app.processEvents()
|
||||||
|
time.sleep(0.15)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Signal should have been emitted
|
||||||
|
assert len(hover_confirmed_spy) == 1
|
||||||
|
assert hover_confirmed_spy[0] == mock_media
|
||||||
|
|
||||||
|
def test_debounce_rapid_hovers(self, tree_view_setup, qt_app):
|
||||||
|
"""Test that rapid hover events are debounced (only last one fires)."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
hover_confirmed_spy = []
|
||||||
|
|
||||||
|
def on_hover_confirmed(media):
|
||||||
|
hover_confirmed_spy.append(media)
|
||||||
|
|
||||||
|
hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
|
||||||
|
|
||||||
|
# Simulate 5 rapid hover events
|
||||||
|
for i in range(5):
|
||||||
|
mock_media = Mock(spec=Track)
|
||||||
|
mock_media.name = f"Track {i}"
|
||||||
|
hover_manager.pending_media = mock_media
|
||||||
|
hover_manager.debounce_timer.stop()
|
||||||
|
hover_manager.debounce_timer.start()
|
||||||
|
time.sleep(0.02) # Small delay between events (< debounce)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Wait for debounce delay
|
||||||
|
time.sleep(0.15)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Only one signal should have been emitted (the last one)
|
||||||
|
assert len(hover_confirmed_spy) == 1
|
||||||
|
assert hover_confirmed_spy[0].name == "Track 4"
|
||||||
|
|
||||||
|
def test_hover_left_cancels_pending(self, tree_view_setup, qt_app):
|
||||||
|
"""Test that leaving hover cancels pending confirmation."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
hover_confirmed_spy = []
|
||||||
|
hover_left_spy = []
|
||||||
|
|
||||||
|
def on_hover_confirmed(media):
|
||||||
|
hover_confirmed_spy.append(media)
|
||||||
|
|
||||||
|
def on_hover_left():
|
||||||
|
hover_left_spy.append(True)
|
||||||
|
|
||||||
|
hover_manager.s_hover_confirmed.connect(on_hover_confirmed)
|
||||||
|
hover_manager.s_hover_left.connect(on_hover_left)
|
||||||
|
|
||||||
|
# Start a hover
|
||||||
|
mock_media = Mock(spec=Track)
|
||||||
|
hover_manager.pending_media = mock_media
|
||||||
|
hover_manager.debounce_timer.start()
|
||||||
|
|
||||||
|
# Leave before debounce completes
|
||||||
|
time.sleep(0.05)
|
||||||
|
qt_app.processEvents()
|
||||||
|
hover_manager._handle_mouse_leave()
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Wait to ensure timer would have fired
|
||||||
|
time.sleep(0.1)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Hover confirmed should NOT have been emitted
|
||||||
|
assert len(hover_confirmed_spy) == 0
|
||||||
|
# Hover left should have been emitted
|
||||||
|
assert len(hover_left_spy) == 1
|
||||||
|
|
||||||
|
def test_set_debounce_delay(self, tree_view_setup, qt_app):
|
||||||
|
"""Test changing the debounce delay."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
hover_manager.set_debounce_delay(200)
|
||||||
|
|
||||||
|
assert hover_manager.debounce_delay_ms == 200
|
||||||
|
assert hover_manager.debounce_timer.interval() == 200
|
||||||
|
|
||||||
|
def test_reset(self, tree_view_setup, qt_app):
|
||||||
|
"""Test resetting the hover manager state."""
|
||||||
|
tree_view, proxy_model, source_model = tree_view_setup
|
||||||
|
|
||||||
|
hover_manager = HoverManager(
|
||||||
|
tree_view=tree_view,
|
||||||
|
proxy_model=proxy_model,
|
||||||
|
source_model=source_model,
|
||||||
|
debounce_delay_ms=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set some state
|
||||||
|
mock_media = Mock(spec=Track)
|
||||||
|
hover_manager.pending_media = mock_media
|
||||||
|
hover_manager.last_hovered_media = mock_media
|
||||||
|
hover_manager.debounce_timer.start()
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
hover_manager.reset()
|
||||||
|
|
||||||
|
assert hover_manager.pending_media is None
|
||||||
|
assert hover_manager.last_hovered_media is None
|
||||||
|
assert not hover_manager.debounce_timer.isActive()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataIntegrity:
|
||||||
|
"""Test that metadata is displayed from pre-loaded data without API calls."""
|
||||||
|
|
||||||
|
def test_no_api_calls_on_hover(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test that hovering does not trigger API calls.
|
||||||
|
|
||||||
|
This test verifies that all data displayed on hover comes from
|
||||||
|
pre-loaded track objects, not from additional API requests.
|
||||||
|
"""
|
||||||
|
# Patch any potential API call methods
|
||||||
|
with patch.object(mock_track, "album") as mock_album:
|
||||||
|
# Ensure album data is already present (pre-loaded)
|
||||||
|
mock_album.name = "A Night at the Opera"
|
||||||
|
mock_album.id = 67890
|
||||||
|
|
||||||
|
# Update on hover
|
||||||
|
info_tab_widget.update_on_hover(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Verify that album was not accessed in a way that would trigger API calls
|
||||||
|
# The album should have been accessed only for reading pre-loaded data
|
||||||
|
assert info_tab_widget.current_media_hovered == mock_track
|
||||||
|
|
||||||
|
def test_all_metadata_from_preloaded_object(self, info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Test that all displayed metadata comes from the track object.
|
||||||
|
|
||||||
|
This verifies that the Track object contains all necessary fields
|
||||||
|
to populate the Details tab without additional API calls.
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.quality_audio_highest", return_value="HI_RES"),
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_artist", return_value="Queen"),
|
||||||
|
patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Bohemian Rhapsody"),
|
||||||
|
):
|
||||||
|
info_tab_widget._populate_track_details(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
assert info_tab_widget.lbl_title.text() != "—"
|
||||||
|
assert info_tab_widget.lbl_version.text() != "—"
|
||||||
|
assert info_tab_widget.lbl_duration.text() != "—"
|
||||||
|
assert info_tab_widget.lbl_popularity.text() != "—"
|
||||||
|
assert info_tab_widget.lbl_bpm.text() != "—"
|
||||||
|
assert info_tab_widget.lbl_isrc.text() != "—"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_fields_initial_state(info_tab_widget, qt_app):
|
||||||
|
"""New fields must exist and default to '—'."""
|
||||||
|
assert hasattr(info_tab_widget, "lbl_track_number")
|
||||||
|
assert info_tab_widget.lbl_track_number.text() == "—"
|
||||||
|
assert hasattr(info_tab_widget, "lbl_bpm")
|
||||||
|
assert info_tab_widget.lbl_bpm.text() == "—"
|
||||||
|
# Note: lbl_label, lbl_genres, lbl_producers and lbl_composers are not available
|
||||||
|
# as these fields cannot be retrieved from TIDAL API
|
||||||
|
|
||||||
|
|
||||||
|
def test_bpm_population(info_tab_widget, mock_track, qt_app):
|
||||||
|
"""Ensure BPM field is populated when present."""
|
||||||
|
# Add BPM attribute to mock_track
|
||||||
|
mock_track.bpm = 120
|
||||||
|
|
||||||
|
with patch("tidal_dl_ng.ui.info_tab_widget.name_builder_title", return_value="Test Track"):
|
||||||
|
info_tab_widget._populate_track_details(mock_track)
|
||||||
|
qt_app.processEvents()
|
||||||
|
|
||||||
|
# Expect loading indicator first due to async extras
|
||||||
|
assert info_tab_widget.lbl_bpm.text() == "⏳ Loading..."
|
||||||
|
# Simulate async arrival of extras
|
||||||
|
info_tab_widget._update_extras_ui({"bpm": 120})
|
||||||
|
qt_app.processEvents()
|
||||||
|
assert info_tab_widget.lbl_bpm.text() == "120"
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import mutagen
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.tidal import (
|
||||||
|
_normalize_contributors,
|
||||||
|
extract_contributor_names,
|
||||||
|
parse_track_and_album_extras,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.metadata import Metadata
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_contributors_dict_shape():
|
||||||
|
raw = {
|
||||||
|
"producer": [{"name": "Prod A"}, {"name": "Prod B", "foo": "bar"}],
|
||||||
|
"composer": [{"name": "Comp 1"}],
|
||||||
|
"invalid": ["x", {"no_name": True}],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _normalize_contributors(raw)
|
||||||
|
|
||||||
|
assert set(result.keys()) == {"producer", "composer"}
|
||||||
|
assert result["producer"] == ["Prod A", "Prod B"]
|
||||||
|
assert result["composer"] == ["Comp 1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_contributors_list_shape():
|
||||||
|
raw = [
|
||||||
|
{"name": "Prod A", "role": "producer"},
|
||||||
|
{"name": "Comp 1", "role": "composer"},
|
||||||
|
{"name": "Bad", "role": ""},
|
||||||
|
"oops",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _normalize_contributors(raw)
|
||||||
|
|
||||||
|
assert set(result.keys()) == {"producer", "composer"}
|
||||||
|
assert result["producer"] == ["Prod A"]
|
||||||
|
assert result["composer"] == ["Comp 1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_track_and_album_extras_full():
|
||||||
|
track_json = {
|
||||||
|
"bpm": 123,
|
||||||
|
"contributors": {
|
||||||
|
"producer": [{"name": "Prod A"}],
|
||||||
|
"composer": [{"name": "Comp 1"}],
|
||||||
|
"lyricist": [{"name": "Writer"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
album_json = {
|
||||||
|
"label": "Test Label",
|
||||||
|
"genres": [{"name": "Rock"}, {"name": "Indie"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
extras = parse_track_and_album_extras(track_json, album_json)
|
||||||
|
|
||||||
|
assert extras["bpm"] == 123
|
||||||
|
assert extras["label"] == "Test Label"
|
||||||
|
assert extras["genres"] == ["Rock", "Indie"]
|
||||||
|
assert extras["contributors_by_role"]["producer"] == ["Prod A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_track_and_album_extras_missing_fields():
|
||||||
|
extras = parse_track_and_album_extras({}, {})
|
||||||
|
|
||||||
|
assert extras["bpm"] is None
|
||||||
|
assert extras["label"] == ""
|
||||||
|
assert extras["genres"] == []
|
||||||
|
assert extras["contributors_by_role"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role,expected",
|
||||||
|
[
|
||||||
|
("producer", "Prod A, Prod B"),
|
||||||
|
("composer", "Comp 1"),
|
||||||
|
("lyricist", ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extract_contributor_names(role, expected):
|
||||||
|
contributors = {
|
||||||
|
"producer": ["Prod A", "Prod B"],
|
||||||
|
"composer": ["Comp 1"],
|
||||||
|
}
|
||||||
|
|
||||||
|
names = extract_contributor_names(contributors, role, delimiter=", ")
|
||||||
|
|
||||||
|
assert names == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_enriched_tags_flac(tmp_path):
|
||||||
|
# Create a valid minimal FLAC file using soundfile
|
||||||
|
path = tmp_path / "test.flac"
|
||||||
|
|
||||||
|
# Import numpy and soundfile to create a minimal valid FLAC
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
# Create 1 second of silence at 44100 Hz
|
||||||
|
silence = np.zeros((44100, 2), dtype=np.float32)
|
||||||
|
sf.write(str(path), silence, 44100, format="FLAC")
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: skip this test if soundfile not available
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.skip("soundfile required for FLAC test")
|
||||||
|
|
||||||
|
m = Metadata(
|
||||||
|
path_file=path,
|
||||||
|
target_upc={"FLAC": "UPC", "MP3": "UPC", "MP4": "UPC"},
|
||||||
|
title="Title",
|
||||||
|
album="Album",
|
||||||
|
artists="Artist1, Artist2",
|
||||||
|
albumartist="AlbumArtist",
|
||||||
|
genre="Rock, Indie",
|
||||||
|
label="Test Label",
|
||||||
|
bpm=120,
|
||||||
|
producers="Prod A, Prod B",
|
||||||
|
composers_detailed="Comp 1, Comp 2",
|
||||||
|
lyricists="Writer 1, Writer 2",
|
||||||
|
)
|
||||||
|
|
||||||
|
m.save()
|
||||||
|
|
||||||
|
audio_out = mutagen.flac.FLAC(path)
|
||||||
|
|
||||||
|
assert audio_out["TITLE"][0] == "Title"
|
||||||
|
assert audio_out["ALBUM"][0] == "Album"
|
||||||
|
assert audio_out["ARTIST"][0] == "Artist1, Artist2"
|
||||||
|
assert audio_out["ALBUMARTIST"][0] == "AlbumArtist"
|
||||||
|
assert audio_out["GENRE"][0] == "Rock, Indie"
|
||||||
|
assert audio_out["LABEL"][0] == "Test Label"
|
||||||
|
assert audio_out["BPM"][0] == "120"
|
||||||
|
assert audio_out["PRODUCER"][0] == "Prod A, Prod B"
|
||||||
|
assert audio_out["LYRICIST"][0] == "Writer 1, Writer 2"
|
||||||
|
assert audio_out["COMPOSER"][0] == "Comp 1, Comp 2"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Cache management for GUI - LRU caches for performance optimization."""
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui
|
||||||
|
|
||||||
|
|
||||||
|
class TrackExtrasCache:
|
||||||
|
"""Thread-safe LRU cache for track extra metadata."""
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = 256):
|
||||||
|
self._lock = QtCore.QReadWriteLock()
|
||||||
|
self._data: dict[str, dict] = {}
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._max_size = max_size
|
||||||
|
|
||||||
|
def get(self, track_id: str) -> dict | None:
|
||||||
|
"""Get cached extras for a track ID."""
|
||||||
|
with QtCore.QReadLocker(self._lock):
|
||||||
|
return self._data.get(track_id)
|
||||||
|
|
||||||
|
def set(self, track_id: str, extras: dict) -> None:
|
||||||
|
"""Cache extras for a track ID with LRU eviction."""
|
||||||
|
with QtCore.QWriteLocker(self._lock):
|
||||||
|
if track_id in self._data:
|
||||||
|
self._order.remove(track_id)
|
||||||
|
self._data[track_id] = extras
|
||||||
|
self._order.append(track_id)
|
||||||
|
if len(self._order) > self._max_size:
|
||||||
|
oldest = self._order.pop(0)
|
||||||
|
self._data.pop(oldest, None)
|
||||||
|
|
||||||
|
|
||||||
|
class CoverPixmapCache:
|
||||||
|
"""Thread-safe LRU cache for cover pixmaps to avoid re-downloading."""
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = 100):
|
||||||
|
self._lock = QtCore.QReadWriteLock()
|
||||||
|
self._pixmaps: dict[str, QtGui.QPixmap] = {}
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._max_size = max_size
|
||||||
|
|
||||||
|
def get(self, cover_url: str) -> QtGui.QPixmap | None:
|
||||||
|
"""Get cached pixmap for a cover URL."""
|
||||||
|
with QtCore.QReadLocker(self._lock):
|
||||||
|
return self._pixmaps.get(cover_url)
|
||||||
|
|
||||||
|
def set(self, cover_url: str, pixmap: QtGui.QPixmap) -> None:
|
||||||
|
"""Cache a pixmap with LRU eviction."""
|
||||||
|
with QtCore.QWriteLocker(self._lock):
|
||||||
|
if cover_url in self._pixmaps:
|
||||||
|
self._order.remove(cover_url)
|
||||||
|
self._pixmaps[cover_url] = pixmap
|
||||||
|
self._order.append(cover_url)
|
||||||
|
if len(self._order) > self._max_size:
|
||||||
|
oldest = self._order.pop(0)
|
||||||
|
self._pixmaps.pop(oldest, None)
|
||||||
+103
-42
@@ -8,6 +8,7 @@ Classes:
|
|||||||
Download: Main class for managing downloads, segment merging, file operations, and metadata.
|
Download: Main class for managing downloads, segment merging, file operations, and metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import random
|
import random
|
||||||
@@ -63,12 +64,16 @@ from tidal_dl_ng.helper.path import (
|
|||||||
url_to_filename,
|
url_to_filename,
|
||||||
)
|
)
|
||||||
from tidal_dl_ng.helper.tidal import (
|
from tidal_dl_ng.helper.tidal import (
|
||||||
|
extract_contributor_names,
|
||||||
|
# new helpers for enriched metadata
|
||||||
|
fetch_raw_track_and_album,
|
||||||
instantiate_media,
|
instantiate_media,
|
||||||
items_results_all,
|
items_results_all,
|
||||||
name_builder_album_artist,
|
name_builder_album_artist,
|
||||||
name_builder_artist,
|
name_builder_artist,
|
||||||
name_builder_item,
|
name_builder_item,
|
||||||
name_builder_title,
|
name_builder_title,
|
||||||
|
parse_track_and_album_extras,
|
||||||
)
|
)
|
||||||
from tidal_dl_ng.metadata import Metadata
|
from tidal_dl_ng.metadata import Metadata
|
||||||
from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo
|
from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo
|
||||||
@@ -1272,67 +1277,123 @@ class Download:
|
|||||||
Returns:
|
Returns:
|
||||||
tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover)
|
tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover)
|
||||||
"""
|
"""
|
||||||
result: bool = False
|
release_date = self._release_date_str(track)
|
||||||
path_lyrics: pathlib.Path | None = None
|
|
||||||
path_cover: pathlib.Path | None = None
|
|
||||||
release_date: str = (
|
|
||||||
track.album.available_release_date.strftime("%Y-%m-%d")
|
|
||||||
if track.album.available_release_date
|
|
||||||
else track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
|
|
||||||
)
|
|
||||||
copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else ""
|
copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else ""
|
||||||
isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else ""
|
isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else ""
|
||||||
lyrics: str = ""
|
lyrics, lyrics_synced, lyrics_unsynced, path_lyrics = self._collect_lyrics(track)
|
||||||
lyrics_synced: str = ""
|
cover_data, path_cover = self._collect_cover(track, is_parent_album)
|
||||||
lyrics_unsynced: str = ""
|
extras = self._fetch_extras(track)
|
||||||
cover_data: bytes = None
|
m = self._build_metadata(
|
||||||
|
track,
|
||||||
|
media_stream,
|
||||||
|
release_date,
|
||||||
|
copy_right,
|
||||||
|
isrc,
|
||||||
|
cover_data,
|
||||||
|
lyrics_synced,
|
||||||
|
lyrics_unsynced,
|
||||||
|
extras,
|
||||||
|
)
|
||||||
|
m.path_file = path_media
|
||||||
|
m.save()
|
||||||
|
return True, path_lyrics, path_cover
|
||||||
|
|
||||||
if self.settings.data.lyrics_embed or self.settings.data.lyrics_file:
|
def _release_date_str(self, track: Track) -> str:
|
||||||
# Try to retrieve lyrics.
|
date = None
|
||||||
|
if hasattr(track, "album") and track.album is not None:
|
||||||
|
date = track.album.available_release_date or track.album.release_date
|
||||||
|
return date.strftime("%Y-%m-%d") if date else ""
|
||||||
|
|
||||||
|
def _collect_lyrics(self, track: Track) -> tuple[str, str, str, pathlib.Path | None]:
|
||||||
|
lyrics = ""
|
||||||
|
lyrics_synced = ""
|
||||||
|
lyrics_unsynced = ""
|
||||||
|
path_lyrics: pathlib.Path | None = None
|
||||||
|
if not (self.settings.data.lyrics_embed or self.settings.data.lyrics_file):
|
||||||
|
return lyrics, lyrics_synced, lyrics_unsynced, None
|
||||||
try:
|
try:
|
||||||
lyrics_obj = track.lyrics()
|
lyrics_obj = track.lyrics()
|
||||||
|
if getattr(lyrics_obj, "text", None):
|
||||||
if lyrics_obj.text:
|
|
||||||
lyrics_unsynced = lyrics_obj.text
|
lyrics_unsynced = lyrics_obj.text
|
||||||
lyrics = lyrics_unsynced
|
lyrics = lyrics_unsynced
|
||||||
if lyrics_obj.subtitles:
|
if getattr(lyrics_obj, "subtitles", None):
|
||||||
lyrics_synced = lyrics_obj.subtitles
|
lyrics_synced = lyrics_obj.subtitles
|
||||||
lyrics = lyrics_synced
|
lyrics = lyrics_synced
|
||||||
except:
|
except Exception:
|
||||||
lyrics = ""
|
lyrics = ""
|
||||||
# TODO: Implement proper logging.
|
|
||||||
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
|
|
||||||
|
|
||||||
if lyrics and self.settings.data.lyrics_file:
|
if lyrics and self.settings.data.lyrics_file:
|
||||||
path_lyrics = self.lyrics_to_file(path_media.parent, lyrics)
|
path_lyrics = self.lyrics_to_file(pathlib.Path(self.path_base), lyrics)
|
||||||
|
return lyrics, lyrics_synced, lyrics_unsynced, path_lyrics
|
||||||
|
|
||||||
|
def _collect_cover(self, track: Track, is_parent_album: bool) -> tuple[bytes | None, pathlib.Path | None]:
|
||||||
|
cover_data: bytes | None = None
|
||||||
|
path_cover: pathlib.Path | None = None
|
||||||
|
if not (self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album)):
|
||||||
|
return None, None
|
||||||
cover_dimension = self.settings.data.metadata_cover_dimension
|
cover_dimension = self.settings.data.metadata_cover_dimension
|
||||||
|
dim = int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280)
|
||||||
if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album):
|
url_cover = track.album.image(dim)
|
||||||
# Do not write CoverDimensions.PxORIGIN to metadata, since it can exceed max metadata file size (>16Mb)
|
cover_data = self.cover_data(url=url_cover) if url_cover else None
|
||||||
url_cover = track.album.image(
|
|
||||||
int(cover_dimension) if cover_dimension != CoverDimensions.PxORIGIN else int(CoverDimensions.Px1280)
|
|
||||||
)
|
|
||||||
cover_data = self.cover_data(url=url_cover)
|
|
||||||
|
|
||||||
if cover_data and self.settings.data.cover_album_file and is_parent_album:
|
if cover_data and self.settings.data.cover_album_file and is_parent_album:
|
||||||
if cover_dimension == CoverDimensions.PxORIGIN:
|
if cover_dimension == CoverDimensions.PxORIGIN:
|
||||||
url_cover_album_file = track.album.image(CoverDimensions.PxORIGIN)
|
url_cover_album_file = track.album.image(CoverDimensions.PxORIGIN)
|
||||||
cover_data_album_file = self.cover_data(url=url_cover_album_file)
|
cover_data_album_file = self.cover_data(url=url_cover_album_file)
|
||||||
else:
|
else:
|
||||||
cover_data_album_file = cover_data
|
cover_data_album_file = cover_data
|
||||||
|
path_cover = self.cover_to_file(pathlib.Path(self.path_base), cover_data_album_file)
|
||||||
|
return cover_data, path_cover
|
||||||
|
|
||||||
path_cover = self.cover_to_file(path_media.parent, cover_data_album_file)
|
def _fetch_extras(self, track: Track) -> dict:
|
||||||
|
extras: dict = {
|
||||||
|
"bpm": None,
|
||||||
|
"label": "",
|
||||||
|
"genres": [],
|
||||||
|
"contributors_by_role": {},
|
||||||
|
}
|
||||||
|
# Use suppress to avoid bare try/except pass
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
track_json, album_json = fetch_raw_track_and_album(
|
||||||
|
self.session, str(track.id), extra_params={"include": "contributors,genres"}
|
||||||
|
)
|
||||||
|
parsed = parse_track_and_album_extras(track_json, album_json)
|
||||||
|
if parsed:
|
||||||
|
extras.update(parsed)
|
||||||
|
return extras
|
||||||
|
|
||||||
|
def _build_metadata(
|
||||||
|
self,
|
||||||
|
track: Track,
|
||||||
|
media_stream: Stream,
|
||||||
|
release_date: str,
|
||||||
|
copy_right: str,
|
||||||
|
isrc: str,
|
||||||
|
cover_data: bytes | None,
|
||||||
|
lyrics_synced: str,
|
||||||
|
lyrics_unsynced: str,
|
||||||
|
extras: dict,
|
||||||
|
) -> Metadata:
|
||||||
metadata_target_upc = MetadataTargetUPC(self.settings.data.metadata_target_upc)
|
metadata_target_upc = MetadataTargetUPC(self.settings.data.metadata_target_upc)
|
||||||
target_upc: dict[str, str] = METADATA_LOOKUP_UPC[metadata_target_upc]
|
target_upc: dict[str, str] = METADATA_LOOKUP_UPC[metadata_target_upc]
|
||||||
explicit: bool = track.explicit if hasattr(track, "explicit") else False
|
explicit: bool = track.explicit if hasattr(track, "explicit") else False
|
||||||
title = name_builder_title(track)
|
title = name_builder_title(track)
|
||||||
title += METADATA_EXPLICIT if explicit and self.settings.data.mark_explicit else ""
|
if explicit and self.settings.data.mark_explicit:
|
||||||
|
title += METADATA_EXPLICIT
|
||||||
# `None` values are not allowed.
|
genres = extras.get("genres") or []
|
||||||
m: Metadata = Metadata(
|
genres_clean = [g for g in genres if isinstance(g, str) and g]
|
||||||
path_file=path_media,
|
genre_display = self.settings.data.metadata_delimiter_artist.join(genres_clean)
|
||||||
|
contributors_by_role = extras.get("contributors_by_role") or {}
|
||||||
|
delimiter = self.settings.data.metadata_delimiter_artist
|
||||||
|
producers = extract_contributor_names(contributors_by_role, "producer", delimiter=delimiter)
|
||||||
|
composers_detailed = extract_contributor_names(
|
||||||
|
contributors_by_role, "composer", delimiter=delimiter
|
||||||
|
) or extract_contributor_names(contributors_by_role, "composers", delimiter=delimiter)
|
||||||
|
lyricists = extract_contributor_names(
|
||||||
|
contributors_by_role, "lyricist", delimiter=delimiter
|
||||||
|
) or extract_contributor_names(contributors_by_role, "lyricists", delimiter=delimiter)
|
||||||
|
bpm_val = extras.get("bpm")
|
||||||
|
bpm: int | None = int(bpm_val) if isinstance(bpm_val, (int | float)) else None
|
||||||
|
return Metadata(
|
||||||
|
path_file=pathlib.Path(self.path_base),
|
||||||
target_upc=target_upc,
|
target_upc=target_upc,
|
||||||
lyrics=lyrics_synced,
|
lyrics=lyrics_synced,
|
||||||
lyrics_unsynced=lyrics_unsynced,
|
lyrics_unsynced=lyrics_unsynced,
|
||||||
@@ -1356,14 +1417,14 @@ class Download:
|
|||||||
replay_gain_write=self.settings.data.metadata_replay_gain,
|
replay_gain_write=self.settings.data.metadata_replay_gain,
|
||||||
upc=track.album.upc if track.album and track.album.upc else "",
|
upc=track.album.upc if track.album and track.album.upc else "",
|
||||||
explicit=explicit,
|
explicit=explicit,
|
||||||
|
genre=genre_display,
|
||||||
|
label=extras.get("label") or "",
|
||||||
|
bpm=bpm,
|
||||||
|
producers=producers,
|
||||||
|
composers_detailed=composers_detailed,
|
||||||
|
lyricists=lyricists,
|
||||||
)
|
)
|
||||||
|
|
||||||
m.save()
|
|
||||||
|
|
||||||
result = True
|
|
||||||
|
|
||||||
return result, path_lyrics, path_cover
|
|
||||||
|
|
||||||
def items(
|
def items(
|
||||||
self,
|
self,
|
||||||
file_template: str,
|
file_template: str,
|
||||||
|
|||||||
+228
-1500
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
|||||||
|
"""Cover management for GUI - Handles cover loading, caching and display."""
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
from PySide6 import QtGui
|
||||||
|
|
||||||
|
from tidal_dl_ng.cache import CoverPixmapCache
|
||||||
|
from tidal_dl_ng.download import Download
|
||||||
|
from tidal_dl_ng.helper.path import resource_path
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
class CoverManager:
|
||||||
|
"""Manages cover art loading, caching and display operations."""
|
||||||
|
|
||||||
|
def __init__(self, parent_window, threadpool, info_tab_widget):
|
||||||
|
"""Initialize the cover manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent_window: Main window instance
|
||||||
|
threadpool: QThreadPool for async operations
|
||||||
|
info_tab_widget: InfoTabWidget instance for display
|
||||||
|
"""
|
||||||
|
self.parent = parent_window
|
||||||
|
self.threadpool = threadpool
|
||||||
|
self.info_tab = info_tab_widget
|
||||||
|
self.cache = CoverPixmapCache()
|
||||||
|
self.cover_url_current = ""
|
||||||
|
|
||||||
|
def load_cover(self, media, use_cache_check: bool = True):
|
||||||
|
"""Load and display cover for media item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media: Media object (Track, Album, etc.)
|
||||||
|
use_cache_check: If True, check cache before loading
|
||||||
|
"""
|
||||||
|
if use_cache_check:
|
||||||
|
# Try cache first for instant display
|
||||||
|
cover_url = self._get_cover_url(media)
|
||||||
|
if cover_url:
|
||||||
|
if cover_url == self.cover_url_current:
|
||||||
|
return # Already displayed
|
||||||
|
|
||||||
|
cached_pixmap = self.cache.get(cover_url)
|
||||||
|
if cached_pixmap:
|
||||||
|
self._display_cover(cached_pixmap, cover_url)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load asynchronously
|
||||||
|
worker = Worker(self._load_cover_async, media)
|
||||||
|
self.threadpool.start(worker)
|
||||||
|
|
||||||
|
def _load_cover_async(self, media):
|
||||||
|
"""Load cover in background thread."""
|
||||||
|
# Emit spinner on tab widget instead of InfoTabWidget (which is QObject, not QWidget)
|
||||||
|
tab_widget = self.info_tab.tab_widget if hasattr(self.info_tab, "tab_widget") else None
|
||||||
|
if tab_widget:
|
||||||
|
self.parent.s_spinner_start.emit(tab_widget)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cover_url = self._get_cover_url(media)
|
||||||
|
|
||||||
|
if cover_url and self.cover_url_current != cover_url:
|
||||||
|
# Check cache again (thread-safe)
|
||||||
|
cached_pixmap = self.cache.get(cover_url)
|
||||||
|
if cached_pixmap:
|
||||||
|
self._display_cover(cached_pixmap, cover_url)
|
||||||
|
else:
|
||||||
|
# Download and cache
|
||||||
|
data_cover = Download.cover_data(cover_url)
|
||||||
|
pixmap = QtGui.QPixmap()
|
||||||
|
pixmap.loadFromData(data_cover)
|
||||||
|
self.cache.set(cover_url, pixmap)
|
||||||
|
self._display_cover(pixmap, cover_url)
|
||||||
|
elif not cover_url:
|
||||||
|
self._display_default_cover()
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.warning(f"Failed to load cover: {e}")
|
||||||
|
self._display_default_cover()
|
||||||
|
finally:
|
||||||
|
self.parent.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def _get_cover_url(self, media) -> str | None:
|
||||||
|
"""Extract cover URL from media object."""
|
||||||
|
with suppress(Exception):
|
||||||
|
if hasattr(media, "album") and media.album:
|
||||||
|
return media.album.image()
|
||||||
|
if hasattr(media, "image") and callable(getattr(media, "image", None)):
|
||||||
|
return media.image()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _display_cover(self, pixmap: QtGui.QPixmap, url: str):
|
||||||
|
"""Display a pixmap on the info tab."""
|
||||||
|
self.info_tab.set_cover_pixmap(pixmap)
|
||||||
|
self.info_tab.cover_url_current = url
|
||||||
|
self.cover_url_current = url
|
||||||
|
|
||||||
|
def _display_default_cover(self):
|
||||||
|
"""Display default cover image."""
|
||||||
|
path_image = resource_path("tidal_dl_ng/ui/default_album_image.png")
|
||||||
|
pixmap = QtGui.QPixmap(path_image)
|
||||||
|
self.info_tab.set_cover_pixmap(pixmap)
|
||||||
|
self.info_tab.cover_url_current = ""
|
||||||
|
self.cover_url_current = ""
|
||||||
|
|
||||||
|
def preload_covers_for_playlist(self, items: list) -> None:
|
||||||
|
"""Preload cover pixmaps for a list of tracks in background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of Track/Video objects to preload covers for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
# Extract unique cover URLs
|
||||||
|
cover_urls = set()
|
||||||
|
for item in items[:50]: # Limit to first 50
|
||||||
|
with suppress(Exception):
|
||||||
|
url = self._get_cover_url(item)
|
||||||
|
if url:
|
||||||
|
cover_urls.add(url)
|
||||||
|
|
||||||
|
# Preload each unique cover
|
||||||
|
for cover_url in cover_urls:
|
||||||
|
if self.cache.get(cover_url):
|
||||||
|
continue # Already cached
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
data_cover = Download.cover_data(cover_url)
|
||||||
|
pixmap = QtGui.QPixmap()
|
||||||
|
pixmap.loadFromData(data_cover)
|
||||||
|
self.cache.set(cover_url, pixmap)
|
||||||
|
logger_gui.debug(f"Preloaded cover: {cover_url[:50]}...")
|
||||||
|
|
||||||
|
worker_obj = Worker(worker)
|
||||||
|
self.threadpool.start(worker_obj)
|
||||||
|
|
||||||
|
def _queue_cover_fetch(self, media):
|
||||||
|
"""Queue a cover fetch operation for a media item."""
|
||||||
|
with suppress(Exception):
|
||||||
|
# previously try/except/pass
|
||||||
|
self.threadpool.start(Worker(self.load_cover, media))
|
||||||
|
|
||||||
|
def _fetch_cover_pixmap(self, media, use_cache_check: bool):
|
||||||
|
"""Fetch cover pixmap for media, with optional cache check."""
|
||||||
|
with suppress(Exception):
|
||||||
|
# previously try/except/continue inside loop
|
||||||
|
pixmap = self.cache.get(self._get_cover_url(media))
|
||||||
|
if pixmap:
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
# If not in cache, download cover
|
||||||
|
cover_url = self._get_cover_url(media)
|
||||||
|
if cover_url:
|
||||||
|
data_cover = Download.cover_data(cover_url)
|
||||||
|
pixmap = QtGui.QPixmap()
|
||||||
|
pixmap.loadFromData(data_cover)
|
||||||
|
self.cache.set(cover_url, pixmap)
|
||||||
|
return pixmap
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# tidal_dl_ng/gui_playlist.py
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtWidgets
|
||||||
|
from tidalapi import Album, Mix, Playlist, UserPlaylist
|
||||||
|
from tidalapi.playlist import Folder
|
||||||
|
|
||||||
|
from tidal_dl_ng.constants import FAVORITES, TidalLists
|
||||||
|
from tidal_dl_ng.helper.gui import get_user_list_media_item, set_user_list_media
|
||||||
|
from tidal_dl_ng.helper.tidal import (
|
||||||
|
favorite_function_factory,
|
||||||
|
items_results_all,
|
||||||
|
user_media_lists,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import StatusbarMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tidal_dl_ng.gui import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class GuiPlaylistManager:
|
||||||
|
"""Manages the playlist, mixes, and favorites GUI and logic."""
|
||||||
|
|
||||||
|
def __init__(self, main_window: "MainWindow"):
|
||||||
|
"""Initialize the playlist manager."""
|
||||||
|
self.main_window: "MainWindow" = main_window
|
||||||
|
self.settings = main_window.settings
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
"""Initialize UI elements related to playlists."""
|
||||||
|
self._init_tree_lists(self.main_window.tr_lists_user)
|
||||||
|
|
||||||
|
def connect_signals(self):
|
||||||
|
"""Connect signals for playlist-related widgets."""
|
||||||
|
self.main_window.pb_reload_user_lists.clicked.connect(lambda: self.main_window.thread_it(self.tidal_user_lists))
|
||||||
|
self.main_window.tr_lists_user.itemClicked.connect(self.on_list_items_show)
|
||||||
|
self.main_window.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded)
|
||||||
|
self.main_window.tr_lists_user.customContextMenuRequested.connect(self.menu_context_tree_lists)
|
||||||
|
self.main_window.s_populate_tree_lists.connect(self.on_populate_tree_lists)
|
||||||
|
self.main_window.s_populate_folder_children.connect(self.on_populate_folder_children)
|
||||||
|
|
||||||
|
def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None:
|
||||||
|
"""Initialize the user lists tree widget."""
|
||||||
|
tree.setColumnWidth(0, 200)
|
||||||
|
tree.setColumnHidden(1, True)
|
||||||
|
tree.setColumnWidth(2, 300)
|
||||||
|
tree.expandAll()
|
||||||
|
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||||
|
|
||||||
|
def tidal_user_lists(self) -> None:
|
||||||
|
"""Fetch and emit user playlists, mixes, and favorites from Tidal."""
|
||||||
|
self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user)
|
||||||
|
self.main_window.s_pb_reload_status.emit(False)
|
||||||
|
user_all = user_media_lists(self.main_window.tidal.session)
|
||||||
|
self.main_window.s_populate_tree_lists.emit(user_all)
|
||||||
|
|
||||||
|
def on_populate_tree_lists(self, user_lists: dict[str, list]) -> None:
|
||||||
|
"""Populate the user lists tree with playlists, mixes, and favorites."""
|
||||||
|
twi_playlists = self.main_window.tr_lists_user.findItems(TidalLists.Playlists, QtCore.Qt.MatchExactly, 0)[0]
|
||||||
|
twi_mixes = self.main_window.tr_lists_user.findItems(TidalLists.Mixes, QtCore.Qt.MatchExactly, 0)[0]
|
||||||
|
twi_favorites = self.main_window.tr_lists_user.findItems(TidalLists.Favorites, QtCore.Qt.MatchExactly, 0)[0]
|
||||||
|
|
||||||
|
for twi in [twi_playlists, twi_mixes, twi_favorites]:
|
||||||
|
for i in reversed(range(twi.childCount())):
|
||||||
|
twi.removeChild(twi.child(i))
|
||||||
|
|
||||||
|
for item in user_lists.get("playlists", []):
|
||||||
|
if isinstance(item, Folder):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||||
|
twi_child.setText(0, f"📁 {item.name}")
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
info = f"({item.total_number_of_items} items)" if item.total_number_of_items else ""
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
dummy_child = QtWidgets.QTreeWidgetItem(twi_child)
|
||||||
|
dummy_child.setDisabled(True)
|
||||||
|
elif isinstance(item, UserPlaylist | Playlist):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
|
||||||
|
name = item.name or ""
|
||||||
|
description = f" {item.description}" if item.description else ""
|
||||||
|
info = f"({item.num_tracks + item.num_videos} Tracks){description}"
|
||||||
|
twi_child.setText(0, name)
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
for item in user_lists.get("mixes", []):
|
||||||
|
if isinstance(item, Mix):
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
|
||||||
|
twi_child.setText(0, item.title)
|
||||||
|
set_user_list_media(twi_child, item)
|
||||||
|
twi_child.setText(2, item.sub_title)
|
||||||
|
|
||||||
|
for key, favorite in FAVORITES.items():
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
|
||||||
|
twi_child.setText(0, favorite["name"])
|
||||||
|
set_user_list_media(twi_child, key)
|
||||||
|
|
||||||
|
self.main_window.s_spinner_stop.emit()
|
||||||
|
self.main_window.s_pb_reload_status.emit(True)
|
||||||
|
|
||||||
|
def menu_context_tree_lists(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Show context menu for user lists tree."""
|
||||||
|
index = self.main_window.tr_lists_user.indexAt(point)
|
||||||
|
if not index.isValid() or not index.parent().data():
|
||||||
|
return
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media = get_user_list_media_item(item)
|
||||||
|
menu = QtWidgets.QMenu()
|
||||||
|
if isinstance(media, Folder):
|
||||||
|
menu.addAction(
|
||||||
|
"Download All Playlists in Folder",
|
||||||
|
lambda: self.main_window.thread_it(self.on_download_folder_playlists, point),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
"Download All Albums from Folder",
|
||||||
|
lambda: self.main_window.thread_it(self.on_download_folder_albums, point),
|
||||||
|
)
|
||||||
|
elif isinstance(media, str):
|
||||||
|
menu.addAction("Download All Items", lambda: self.main_window.thread_it(self.on_download_favorites, point))
|
||||||
|
menu.addAction(
|
||||||
|
"Download All Albums from Items",
|
||||||
|
lambda: self.main_window.thread_it(self.on_download_albums_from_favorites, point),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
menu.addAction("Download Playlist", lambda: self.main_window.thread_it(self.on_download_list_media, point))
|
||||||
|
menu.addAction(
|
||||||
|
"Download All Albums in Playlist",
|
||||||
|
lambda: self.main_window.thread_it(self.on_download_all_albums_from_playlist, point),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
"Copy Share URL", lambda: self.main_window.on_copy_url_share(self.main_window.tr_lists_user, point)
|
||||||
|
)
|
||||||
|
menu.exec_(self.main_window.tr_lists_user.mapToGlobal(point))
|
||||||
|
|
||||||
|
def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None:
|
||||||
|
"""Download all media items in a selected list."""
|
||||||
|
items = (
|
||||||
|
[self.main_window.tr_lists_user.itemAt(point)] if point else self.main_window.tr_lists_user.selectedItems()
|
||||||
|
)
|
||||||
|
if not items:
|
||||||
|
logger_gui.error("Please select a mix or playlist first.")
|
||||||
|
return
|
||||||
|
for item in items:
|
||||||
|
media = get_user_list_media_item(item)
|
||||||
|
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media)
|
||||||
|
if queue_dl_item:
|
||||||
|
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||||
|
|
||||||
|
def on_download_folder_playlists(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all playlists in a folder."""
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media = get_user_list_media_item(item)
|
||||||
|
if not isinstance(media, Folder):
|
||||||
|
logger_gui.error("Please select a folder.")
|
||||||
|
return
|
||||||
|
logger_gui.info(f"Fetching playlists from folder: {media.name}")
|
||||||
|
playlists = self._get_folder_playlists(media)
|
||||||
|
if not playlists:
|
||||||
|
logger_gui.info(f"No playlists found in folder: {media.name}")
|
||||||
|
return
|
||||||
|
logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}")
|
||||||
|
for playlist in playlists:
|
||||||
|
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(playlist)
|
||||||
|
if queue_dl_item:
|
||||||
|
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||||
|
logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}")
|
||||||
|
|
||||||
|
def on_download_folder_albums(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all unique albums from all playlists in a folder."""
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media = get_user_list_media_item(item)
|
||||||
|
if not isinstance(media, Folder):
|
||||||
|
logger_gui.error("Please select a folder.")
|
||||||
|
return
|
||||||
|
playlists = self._get_folder_playlists(media)
|
||||||
|
if not playlists:
|
||||||
|
return
|
||||||
|
all_tracks = []
|
||||||
|
for playlist in playlists:
|
||||||
|
try:
|
||||||
|
tracks = self._get_playlist_tracks(playlist)
|
||||||
|
all_tracks.extend(tracks)
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}")
|
||||||
|
if not all_tracks:
|
||||||
|
return
|
||||||
|
album_ids = self.main_window._extract_album_ids_from_tracks(all_tracks)
|
||||||
|
if not album_ids:
|
||||||
|
return
|
||||||
|
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||||
|
if not albums_dict:
|
||||||
|
return
|
||||||
|
self.main_window._queue_loaded_albums(albums_dict)
|
||||||
|
|
||||||
|
def on_download_favorites(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all items from a Favorites category."""
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media_key = get_user_list_media_item(item)
|
||||||
|
if not isinstance(media_key, str):
|
||||||
|
logger_gui.error("Please select a favorites category.")
|
||||||
|
return
|
||||||
|
favorite_name = FAVORITES.get(media_key, {}).get("name", media_key)
|
||||||
|
logger_gui.info(f"Fetching all items from favorites: {favorite_name}")
|
||||||
|
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||||
|
media_items = favorite_function()
|
||||||
|
if not media_items:
|
||||||
|
logger_gui.info(f"No items found in favorites: {favorite_name}")
|
||||||
|
return
|
||||||
|
queued_count = 0
|
||||||
|
for media_item in media_items:
|
||||||
|
queue_dl_item = self.main_window.queue_manager.media_to_queue_download_model(media_item)
|
||||||
|
if queue_dl_item:
|
||||||
|
self.main_window.queue_manager.queue_download_media(queue_dl_item)
|
||||||
|
queued_count += 1
|
||||||
|
logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}")
|
||||||
|
|
||||||
|
def _download_albums_from_fav_artists(self, media_items: list) -> None:
|
||||||
|
"""Download all albums from favorite artists."""
|
||||||
|
all_albums = {}
|
||||||
|
for artist in media_items:
|
||||||
|
try:
|
||||||
|
artist_albums = items_results_all(self.main_window.tidal.session, artist)
|
||||||
|
for album in artist_albums:
|
||||||
|
if isinstance(album, Album) and album.id:
|
||||||
|
all_albums[album.id] = album
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}")
|
||||||
|
if all_albums:
|
||||||
|
self.main_window._queue_loaded_albums(all_albums)
|
||||||
|
|
||||||
|
def _download_albums_from_fav_tracks(self, media_items: list) -> None:
|
||||||
|
"""Download all albums from favorite tracks."""
|
||||||
|
album_ids = self.main_window._extract_album_ids_from_tracks(media_items)
|
||||||
|
if album_ids:
|
||||||
|
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||||
|
if albums_dict:
|
||||||
|
self.main_window._queue_loaded_albums(albums_dict)
|
||||||
|
|
||||||
|
def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all unique albums from items in a Favorites category."""
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media_key = get_user_list_media_item(item)
|
||||||
|
if not isinstance(media_key, str):
|
||||||
|
return
|
||||||
|
favorite_function = favorite_function_factory(self.main_window.tidal, media_key)
|
||||||
|
media_items = favorite_function()
|
||||||
|
if not media_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
if media_key == "fav_albums":
|
||||||
|
albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id}
|
||||||
|
self.main_window._queue_loaded_albums(albums_dict)
|
||||||
|
elif media_key == "fav_artists":
|
||||||
|
self._download_albums_from_fav_artists(media_items)
|
||||||
|
else:
|
||||||
|
self._download_albums_from_fav_tracks(media_items)
|
||||||
|
|
||||||
|
def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Download all unique albums from tracks in a playlist."""
|
||||||
|
item = self.main_window.tr_lists_user.itemAt(point)
|
||||||
|
media_list = get_user_list_media_item(item)
|
||||||
|
if not isinstance(media_list, Playlist | UserPlaylist | Mix):
|
||||||
|
logger_gui.error("Please select a playlist or mix.")
|
||||||
|
return
|
||||||
|
media_items = items_results_all(self.main_window.tidal.session, media_list)
|
||||||
|
album_ids = self.main_window._extract_album_ids_from_tracks(media_items)
|
||||||
|
if not album_ids:
|
||||||
|
logger_gui.warning("No albums found in this playlist.")
|
||||||
|
return
|
||||||
|
albums_dict = self.main_window._load_albums_with_rate_limiting(album_ids)
|
||||||
|
if not albums_dict:
|
||||||
|
logger_gui.error("Failed to load any albums from playlist.")
|
||||||
|
return
|
||||||
|
self.main_window._queue_loaded_albums(albums_dict)
|
||||||
|
message = f"Added {len(albums_dict)} albums to download queue"
|
||||||
|
self.main_window.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000))
|
||||||
|
|
||||||
|
def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Handle expansion of folders in the user lists tree."""
|
||||||
|
if item.childCount() > 0 and item.child(0).isDisabled():
|
||||||
|
self.main_window.thread_it(self.tr_lists_user_load_folder_children, item)
|
||||||
|
|
||||||
|
def tr_lists_user_load_folder_children(self, parent_item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Load and display children of a folder in the user lists tree."""
|
||||||
|
folder = get_user_list_media_item(parent_item)
|
||||||
|
if not isinstance(folder, Folder):
|
||||||
|
return
|
||||||
|
self.main_window.s_spinner_start.emit(self.main_window.tr_lists_user)
|
||||||
|
try:
|
||||||
|
folders, playlists = self._fetch_folder_contents(folder)
|
||||||
|
self.main_window.s_populate_folder_children.emit(parent_item, folders, playlists)
|
||||||
|
finally:
|
||||||
|
self.main_window.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def on_populate_folder_children(
|
||||||
|
self, parent_item: QtWidgets.QTreeWidgetItem, folders: list[Folder], playlists: list[Playlist]
|
||||||
|
) -> None:
|
||||||
|
"""Populate folder children in the main thread."""
|
||||||
|
parent_item.takeChild(0)
|
||||||
|
for sub_folder in folders:
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||||
|
twi_child.setText(0, f"📁 {sub_folder.name}")
|
||||||
|
set_user_list_media(twi_child, sub_folder)
|
||||||
|
info = f"({sub_folder.total_number_of_items} items)" if sub_folder.total_number_of_items else ""
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
dummy = QtWidgets.QTreeWidgetItem(twi_child)
|
||||||
|
dummy.setDisabled(True)
|
||||||
|
for playlist in playlists:
|
||||||
|
twi_child = QtWidgets.QTreeWidgetItem(parent_item)
|
||||||
|
twi_child.setText(0, playlist.name or "")
|
||||||
|
set_user_list_media(twi_child, playlist)
|
||||||
|
info = f"({playlist.num_tracks + playlist.num_videos} Tracks)"
|
||||||
|
if playlist.description:
|
||||||
|
info += f" {playlist.description}"
|
||||||
|
twi_child.setText(2, info)
|
||||||
|
|
||||||
|
def _fetch_folder_contents(self, folder: Folder) -> tuple[list[Folder], list[Playlist]]:
|
||||||
|
"""Fetch contents of a folder."""
|
||||||
|
folder_id = folder.id or "root"
|
||||||
|
folders, playlists = [], []
|
||||||
|
offset, limit = 0, 50
|
||||||
|
while True:
|
||||||
|
batch = self.main_window.tidal.session.user.favorites.playlist_folders(
|
||||||
|
limit=limit, offset=offset, parent_folder_id=folder_id
|
||||||
|
)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
folders.extend(batch)
|
||||||
|
if len(batch) < limit:
|
||||||
|
break
|
||||||
|
offset += limit
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
batch = folder.items(offset=offset, limit=limit)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
playlists.extend(batch)
|
||||||
|
if len(batch) < limit:
|
||||||
|
break
|
||||||
|
offset += limit
|
||||||
|
return folders, playlists
|
||||||
|
|
||||||
|
def _get_folder_playlists(self, folder: Folder) -> list[Playlist]:
|
||||||
|
"""Fetch all playlists from a folder."""
|
||||||
|
_, playlists = self._fetch_folder_contents(folder)
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list:
|
||||||
|
"""Fetch all tracks from a playlist."""
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in items_results_all(self.main_window.tidal.session, playlist)
|
||||||
|
if isinstance(item, self.main_window.tidal.session.track)
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Show the items in the selected playlist or mix."""
|
||||||
|
self.main_window.thread_it(self.list_items_show, item)
|
||||||
|
|
||||||
|
def list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Fetch and display the items in a playlist, mix, or folder."""
|
||||||
|
media_list = get_user_list_media_item(item)
|
||||||
|
if not media_list:
|
||||||
|
return
|
||||||
|
self.main_window.s_spinner_start.emit(self.main_window.tr_results)
|
||||||
|
try:
|
||||||
|
if isinstance(media_list, Folder):
|
||||||
|
self._show_folder_contents(media_list)
|
||||||
|
elif isinstance(media_list, str) and media_list.startswith("fav_"):
|
||||||
|
function_list = favorite_function_factory(self.main_window.tidal, media_list)
|
||||||
|
self.main_window.list_items_show_result(favorite_function=function_list)
|
||||||
|
else:
|
||||||
|
self.main_window.list_items_show_result(media_list)
|
||||||
|
self.main_window.thread_it(self.main_window.cover_manager.load_cover, media_list)
|
||||||
|
finally:
|
||||||
|
self.main_window.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def _show_folder_contents(self, folder: Folder) -> None:
|
||||||
|
"""Display folder contents in results pane."""
|
||||||
|
folders, playlists = self._fetch_folder_contents(folder)
|
||||||
|
items = folders + playlists
|
||||||
|
result = self.main_window.search_manager.search_result_to_model(items)
|
||||||
|
self.main_window.search_manager.populate_tree_results(result)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# tidal_dl_ng/gui_queue.py
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Artist, Mix, Playlist, Quality, Track, Video
|
||||||
|
|
||||||
|
from tidal_dl_ng.config import HandlingApp, Settings
|
||||||
|
from tidal_dl_ng.constants import QualityVideo, QueueDownloadStatus
|
||||||
|
from tidal_dl_ng.download import Download
|
||||||
|
from tidal_dl_ng.helper.gui import (
|
||||||
|
get_queue_download_media,
|
||||||
|
get_queue_download_quality_audio,
|
||||||
|
get_queue_download_quality_video,
|
||||||
|
set_queue_download_media,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.helper.path import get_format_template
|
||||||
|
from tidal_dl_ng.helper.tidal import items_results_all, name_builder_artist, name_builder_title, quality_audio_highest
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import QueueDownloadItem, StatusbarMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tidal_dl_ng.gui import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class GuiQueueManager:
|
||||||
|
"""Manages the download queue GUI and logic."""
|
||||||
|
|
||||||
|
def __init__(self, main_window: "MainWindow"):
|
||||||
|
"""Initialize the queue manager."""
|
||||||
|
self.main_window: "MainWindow" = main_window
|
||||||
|
self.settings: Settings = main_window.settings
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
"""Initialize UI elements related to the queue."""
|
||||||
|
self._init_tree_queue(self.main_window.tr_queue_download)
|
||||||
|
self.pb_queue_download_run()
|
||||||
|
|
||||||
|
def connect_signals(self):
|
||||||
|
"""Connect signals for queue-related widgets."""
|
||||||
|
self.main_window.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all)
|
||||||
|
self.main_window.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished)
|
||||||
|
self.main_window.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove)
|
||||||
|
self.main_window.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle)
|
||||||
|
self.main_window.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked)
|
||||||
|
self.main_window.tr_queue_download.customContextMenuRequested.connect(self.menu_context_queue_download)
|
||||||
|
self.main_window.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading)
|
||||||
|
self.main_window.s_queue_download_item_finished.connect(self.on_queue_download_item_finished)
|
||||||
|
self.main_window.s_queue_download_item_failed.connect(self.on_queue_download_item_failed)
|
||||||
|
self.main_window.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped)
|
||||||
|
|
||||||
|
def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None:
|
||||||
|
"""Initialize the download queue table widget."""
|
||||||
|
tree.setColumnHidden(1, True)
|
||||||
|
tree.setColumnWidth(2, 200)
|
||||||
|
header = tree.header()
|
||||||
|
if hasattr(header, "setSectionResizeMode"):
|
||||||
|
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
||||||
|
tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||||
|
|
||||||
|
def menu_context_queue_download(self, point: QtCore.QPoint) -> None:
|
||||||
|
"""Show context menu for download queue."""
|
||||||
|
item = self.main_window.tr_queue_download.itemAt(point)
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
menu = QtWidgets.QMenu()
|
||||||
|
status = item.text(0)
|
||||||
|
if status == QueueDownloadStatus.Waiting:
|
||||||
|
menu.addAction("🗑️ Remove from Queue", lambda: self.on_queue_download_remove_item(item))
|
||||||
|
if menu.isEmpty():
|
||||||
|
return
|
||||||
|
menu.exec(self.main_window.tr_queue_download.mapToGlobal(point))
|
||||||
|
|
||||||
|
def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Remove a specific item from the download queue."""
|
||||||
|
index = self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||||
|
if index >= 0:
|
||||||
|
self.main_window.tr_queue_download.takeTopLevelItem(index)
|
||||||
|
logger_gui.info("Removed item from download queue")
|
||||||
|
|
||||||
|
def on_queue_download_clear_all(self) -> None:
|
||||||
|
"""Clear all items from the download queue."""
|
||||||
|
self.on_clear_queue_download(
|
||||||
|
f"({QueueDownloadStatus.Waiting}|{QueueDownloadStatus.Finished}|{QueueDownloadStatus.Failed}|{QueueDownloadStatus.Skipped})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_queue_download_clear_finished(self) -> None:
|
||||||
|
"""Clear finished items from the download queue."""
|
||||||
|
self.on_clear_queue_download(f"({QueueDownloadStatus.Finished}|{QueueDownloadStatus.Skipped})")
|
||||||
|
|
||||||
|
def on_clear_queue_download(self, regex: str) -> None:
|
||||||
|
"""Clear items from the download queue matching the given regex."""
|
||||||
|
items = self.main_window.tr_queue_download.findItems(
|
||||||
|
regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||||
|
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_queue_download_remove(self) -> None:
|
||||||
|
"""Remove selected items from the download queue."""
|
||||||
|
items = self.main_window.tr_queue_download.selectedItems()
|
||||||
|
if not items:
|
||||||
|
logger_gui.error("Please select an item from the queue first.")
|
||||||
|
else:
|
||||||
|
for item in items:
|
||||||
|
status: str = item.text(0)
|
||||||
|
if status != QueueDownloadStatus.Downloading:
|
||||||
|
self.main_window.tr_queue_download.takeTopLevelItem(
|
||||||
|
self.main_window.tr_queue_download.indexOfTopLevelItem(item)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger_gui.info("Cannot remove a currently downloading item from queue.")
|
||||||
|
|
||||||
|
def on_pb_queue_download_toggle(self) -> None:
|
||||||
|
"""Toggle download status (pause / resume) accordingly."""
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
if handling_app.event_run.is_set():
|
||||||
|
self.pb_queue_download_pause()
|
||||||
|
else:
|
||||||
|
self.pb_queue_download_run()
|
||||||
|
|
||||||
|
def pb_queue_download_run(self) -> None:
|
||||||
|
"""Start the download queue and update the button state."""
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
handling_app.event_run.set()
|
||||||
|
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackPause))
|
||||||
|
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||||
|
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #e0a800; color: #212529")
|
||||||
|
|
||||||
|
def pb_queue_download_pause(self) -> None:
|
||||||
|
"""Pause the download queue and update the button state."""
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
handling_app.event_run.clear()
|
||||||
|
icon = QtGui.QIcon(QtGui.QIcon.fromTheme(QtGui.QIcon.ThemeIcon.MediaPlaybackStart))
|
||||||
|
self.main_window.pb_queue_download_toggle.setIcon(icon)
|
||||||
|
self.main_window.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff")
|
||||||
|
|
||||||
|
def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None:
|
||||||
|
"""Add a media item to the download queue."""
|
||||||
|
child = QtWidgets.QTreeWidgetItem()
|
||||||
|
child.setText(0, queue_dl_item.status)
|
||||||
|
set_queue_download_media(child, queue_dl_item.obj)
|
||||||
|
child.setText(2, queue_dl_item.name)
|
||||||
|
child.setText(3, queue_dl_item.type_media)
|
||||||
|
child.setText(4, str(queue_dl_item.quality_audio))
|
||||||
|
child.setText(5, str(queue_dl_item.quality_video))
|
||||||
|
self.main_window.tr_queue_download.addTopLevelItem(child)
|
||||||
|
|
||||||
|
def watcher_queue_download(self) -> None:
|
||||||
|
"""Monitor the download queue and process items as they become available."""
|
||||||
|
handling_app: HandlingApp = HandlingApp()
|
||||||
|
while not handling_app.event_abort.is_set():
|
||||||
|
items = self.main_window.tr_queue_download.findItems(
|
||||||
|
QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0
|
||||||
|
)
|
||||||
|
if items:
|
||||||
|
item = items[0]
|
||||||
|
media = get_queue_download_media(item)
|
||||||
|
quality_audio = get_queue_download_quality_audio(item)
|
||||||
|
quality_video = get_queue_download_quality_video(item)
|
||||||
|
try:
|
||||||
|
self.main_window.s_queue_download_item_downloading.emit(item)
|
||||||
|
result = self.on_queue_download(media, quality_audio=quality_audio, quality_video=quality_video)
|
||||||
|
if result == QueueDownloadStatus.Finished:
|
||||||
|
self.main_window.s_queue_download_item_finished.emit(item)
|
||||||
|
elif result == QueueDownloadStatus.Skipped:
|
||||||
|
self.main_window.s_queue_download_item_skipped.emit(item)
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.error(e)
|
||||||
|
self.main_window.s_queue_download_item_failed.emit(item)
|
||||||
|
else:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def on_queue_download_item_downloading(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Downloading'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Downloading)
|
||||||
|
|
||||||
|
def on_queue_download_item_finished(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Finished'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Finished)
|
||||||
|
|
||||||
|
def on_queue_download_item_failed(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Failed'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Failed)
|
||||||
|
|
||||||
|
def on_queue_download_item_skipped(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Update the status of a queue download item to 'Skipped'."""
|
||||||
|
self.queue_download_item_status(item, QueueDownloadStatus.Skipped)
|
||||||
|
|
||||||
|
def queue_download_item_status(self, item: QtWidgets.QTreeWidgetItem, status: str) -> None:
|
||||||
|
"""Set the status text of a queue download item."""
|
||||||
|
item.setText(0, status)
|
||||||
|
|
||||||
|
def on_queue_download(
|
||||||
|
self,
|
||||||
|
media: Track | Album | Playlist | Video | Mix | Artist,
|
||||||
|
quality_audio: Quality | None = None,
|
||||||
|
quality_video: QualityVideo | None = None,
|
||||||
|
) -> QueueDownloadStatus:
|
||||||
|
"""Download the specified media item(s) and return the result status."""
|
||||||
|
items_media = items_results_all(self.main_window.tidal.session, media) if isinstance(media, Artist) else [media]
|
||||||
|
download_delay = bool(isinstance(media, Track | Video) and self.settings.data.download_delay)
|
||||||
|
result = QueueDownloadStatus.Failed
|
||||||
|
for item_media in items_media:
|
||||||
|
result = self.download(
|
||||||
|
item_media,
|
||||||
|
self.main_window.dl,
|
||||||
|
delay_track=download_delay,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
media: Track | Album | Playlist | Video | Mix,
|
||||||
|
dl: Download,
|
||||||
|
delay_track: bool = False,
|
||||||
|
quality_audio: Quality | None = None,
|
||||||
|
quality_video: QualityVideo | None = None,
|
||||||
|
) -> QueueDownloadStatus:
|
||||||
|
"""Download a media item and return the result status."""
|
||||||
|
self.main_window.s_pb_reset.emit()
|
||||||
|
self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download started..."))
|
||||||
|
file_template = get_format_template(media, self.settings)
|
||||||
|
result_dl, path_file = False, None
|
||||||
|
if isinstance(media, Track | Video):
|
||||||
|
result_dl, path_file = dl.item(
|
||||||
|
media=media,
|
||||||
|
file_template=file_template,
|
||||||
|
download_delay=delay_track,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
)
|
||||||
|
elif isinstance(media, Album | Playlist | Mix):
|
||||||
|
dl.items(
|
||||||
|
media=media,
|
||||||
|
file_template=file_template,
|
||||||
|
video_download=self.settings.data.video_download,
|
||||||
|
download_delay=self.settings.data.download_delay,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=quality_video,
|
||||||
|
)
|
||||||
|
result_dl, path_file = True, "dummy"
|
||||||
|
self.main_window.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000))
|
||||||
|
if result_dl and path_file:
|
||||||
|
return QueueDownloadStatus.Finished
|
||||||
|
if not result_dl and path_file:
|
||||||
|
return QueueDownloadStatus.Skipped
|
||||||
|
return QueueDownloadStatus.Failed
|
||||||
|
|
||||||
|
def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem) -> None:
|
||||||
|
"""Handle the event when a queue download item is clicked."""
|
||||||
|
media = get_queue_download_media(item)
|
||||||
|
self.main_window.info_tab_widget.update_on_selection(media)
|
||||||
|
self.main_window.thread_it(self.main_window.cover_manager.load_cover, media)
|
||||||
|
|
||||||
|
def media_to_queue_download_model(
|
||||||
|
self, media: Artist | Track | Video | Album | Playlist | Mix
|
||||||
|
) -> QueueDownloadItem | None:
|
||||||
|
"""Convert a media object to a QueueDownloadItem for the download queue."""
|
||||||
|
if hasattr(media, "available") and media.available is False:
|
||||||
|
return None
|
||||||
|
explicit = " 🅴" if isinstance(media, Track | Video | Album) and media.explicit else ""
|
||||||
|
name = ""
|
||||||
|
if isinstance(media, Track | Video):
|
||||||
|
name = f"{name_builder_artist(media)} - {name_builder_title(media)}{explicit}"
|
||||||
|
elif isinstance(media, Playlist | Artist):
|
||||||
|
name = media.name
|
||||||
|
elif isinstance(media, Album):
|
||||||
|
name = f"{name_builder_artist(media)} - {media.name}{explicit}"
|
||||||
|
elif isinstance(media, Mix):
|
||||||
|
name = media.title
|
||||||
|
|
||||||
|
quality_audio = self.settings.data.quality_audio
|
||||||
|
if isinstance(media, Track | Album):
|
||||||
|
quality_highest = quality_audio_highest(media)
|
||||||
|
if (
|
||||||
|
self.settings.data.quality_audio == quality_highest
|
||||||
|
or self.settings.data.quality_audio == Quality.hi_res_lossless
|
||||||
|
):
|
||||||
|
quality_audio = quality_highest
|
||||||
|
|
||||||
|
if name:
|
||||||
|
return QueueDownloadItem(
|
||||||
|
name=name,
|
||||||
|
quality_audio=quality_audio,
|
||||||
|
quality_video=self.settings.data.quality_video,
|
||||||
|
type_media=type(media).__name__,
|
||||||
|
status=QueueDownloadStatus.Waiting,
|
||||||
|
obj=media,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# tidal_dl_ng/gui_search.py
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from tidalapi import Album, Artist, Mix, Playlist, Track, Video
|
||||||
|
from tidalapi.media import AudioMode
|
||||||
|
from tidalapi.playlist import Folder
|
||||||
|
from tidalapi.session import SearchTypes
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.tidal import (
|
||||||
|
get_tidal_media_id,
|
||||||
|
get_tidal_media_type,
|
||||||
|
instantiate_media,
|
||||||
|
name_builder_artist,
|
||||||
|
quality_audio_highest,
|
||||||
|
search_results_all,
|
||||||
|
url_ending_clean,
|
||||||
|
)
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
from tidal_dl_ng.model.gui_data import ResultItem
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from tidal_dl_ng.gui import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class GuiSearchManager:
|
||||||
|
"""Manages the search GUI and logic."""
|
||||||
|
|
||||||
|
def __init__(self, main_window: "MainWindow"):
|
||||||
|
"""Initialize the search manager."""
|
||||||
|
self.main_window: "MainWindow" = main_window
|
||||||
|
|
||||||
|
def search_populate_results(self, query: str, type_media: Any) -> None:
|
||||||
|
"""Populate the results tree with search results."""
|
||||||
|
results = self.search(query, [type_media])
|
||||||
|
self.main_window.populate_tree_results(results)
|
||||||
|
|
||||||
|
def search(self, query: str, types_media: list[Any]) -> list[ResultItem]:
|
||||||
|
"""Perform a search and return a list of ResultItems."""
|
||||||
|
query_clean = query.strip()
|
||||||
|
if "http" in query_clean:
|
||||||
|
query_clean = url_ending_clean(query_clean)
|
||||||
|
media_type = get_tidal_media_type(query_clean)
|
||||||
|
item_id = get_tidal_media_id(query_clean)
|
||||||
|
try:
|
||||||
|
media = instantiate_media(self.main_window.tidal.session, media_type, item_id)
|
||||||
|
result_search = {"direct": [media]}
|
||||||
|
except Exception:
|
||||||
|
logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.")
|
||||||
|
result_search = {"direct": []}
|
||||||
|
else:
|
||||||
|
result_search = search_results_all(
|
||||||
|
session=self.main_window.tidal.session, needle=query_clean, types_media=types_media
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for _media_type, l_media in result_search.items():
|
||||||
|
if isinstance(l_media, list):
|
||||||
|
result.extend(self.search_result_to_model(l_media))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def search_result_to_model(self, items: list[SearchTypes]) -> list[ResultItem]:
|
||||||
|
"""Convert search results to ResultItem models."""
|
||||||
|
return [self._to_result_item(idx, item) for idx, item in enumerate(items) if self._to_result_item(idx, item)]
|
||||||
|
|
||||||
|
def _to_result_item(self, idx: int, item: Any) -> ResultItem | None:
|
||||||
|
"""Helper to convert a single item to ResultItem."""
|
||||||
|
if not item or (hasattr(item, "available") and not item.available):
|
||||||
|
return None
|
||||||
|
|
||||||
|
explicit = " 🅴" if isinstance(item, Track | Video | Album) and item.explicit else ""
|
||||||
|
date_user_added = (
|
||||||
|
item.user_date_added.strftime("%Y-%m-%d_%H:%M") if getattr(item, "user_date_added", None) else ""
|
||||||
|
)
|
||||||
|
date_release = self._get_date_release(item)
|
||||||
|
|
||||||
|
type_handlers = {
|
||||||
|
Track: self._result_item_from_track,
|
||||||
|
Video: self._result_item_from_video,
|
||||||
|
Playlist: self._result_item_from_playlist,
|
||||||
|
Album: self._result_item_from_album,
|
||||||
|
Mix: self._result_item_from_mix,
|
||||||
|
Artist: self._result_item_from_artist,
|
||||||
|
Folder: self._result_item_from_folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
for item_type, handler in type_handlers.items():
|
||||||
|
if isinstance(item, item_type):
|
||||||
|
return handler(idx, item, explicit, date_user_added, date_release)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_date_release(self, item: Any) -> str:
|
||||||
|
"""Get the release date string for an item."""
|
||||||
|
if hasattr(item, "album") and item.album and getattr(item.album, "release_date", None):
|
||||||
|
return item.album.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||||
|
if hasattr(item, "release_date") and item.release_date:
|
||||||
|
return item.release_date.strftime("%Y-%m-%d_%H:%M")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _result_item_from_track(
|
||||||
|
self, idx: int, item: Track, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from a Track."""
|
||||||
|
final_quality = quality_audio_highest(item)
|
||||||
|
if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes:
|
||||||
|
final_quality = f"{final_quality} / Dolby Atmos"
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=name_builder_artist(item),
|
||||||
|
title=f"{item.name}{explicit}",
|
||||||
|
album=item.album.name,
|
||||||
|
duration_sec=item.duration,
|
||||||
|
obj=item,
|
||||||
|
quality=final_quality,
|
||||||
|
explicit=bool(item.explicit),
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_video(
|
||||||
|
self, idx: int, item: Video, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from a Video."""
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=name_builder_artist(item),
|
||||||
|
title=f"{item.name}{explicit}",
|
||||||
|
album=item.album.name if item.album else "",
|
||||||
|
duration_sec=item.duration,
|
||||||
|
obj=item,
|
||||||
|
quality=item.video_quality,
|
||||||
|
explicit=bool(item.explicit),
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_playlist(
|
||||||
|
self, idx: int, item: Playlist, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from a Playlist."""
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=", ".join(artist.name for artist in item.promoted_artists) if item.promoted_artists else "",
|
||||||
|
title=item.name,
|
||||||
|
album="",
|
||||||
|
duration_sec=item.duration,
|
||||||
|
obj=item,
|
||||||
|
quality="",
|
||||||
|
explicit=False,
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_album(
|
||||||
|
self, idx: int, item: Album, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from an Album."""
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=name_builder_artist(item),
|
||||||
|
title="",
|
||||||
|
album=f"{item.name}{explicit}",
|
||||||
|
duration_sec=item.duration,
|
||||||
|
obj=item,
|
||||||
|
quality=quality_audio_highest(item),
|
||||||
|
explicit=bool(item.explicit),
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_mix(
|
||||||
|
self, idx: int, item: Mix, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from a Mix."""
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=item.sub_title,
|
||||||
|
title=item.title,
|
||||||
|
album="",
|
||||||
|
duration_sec=-1,
|
||||||
|
obj=item,
|
||||||
|
quality="",
|
||||||
|
explicit=False,
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_artist(
|
||||||
|
self, idx: int, item: Artist, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from an Artist."""
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist=item.name,
|
||||||
|
title="",
|
||||||
|
album="",
|
||||||
|
duration_sec=-1,
|
||||||
|
obj=item,
|
||||||
|
quality="",
|
||||||
|
explicit=False,
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _result_item_from_folder(
|
||||||
|
self, idx: int, item: Folder, explicit: str, date_user_added: str, date_release: str
|
||||||
|
) -> ResultItem:
|
||||||
|
"""Create a ResultItem from a Folder."""
|
||||||
|
total_items = item.total_number_of_items if hasattr(item, "total_number_of_items") else 0
|
||||||
|
return ResultItem(
|
||||||
|
position=idx,
|
||||||
|
artist="",
|
||||||
|
title=f"📁 {item.name} ({total_items} items)",
|
||||||
|
album="",
|
||||||
|
duration_sec=-1,
|
||||||
|
obj=item,
|
||||||
|
quality="",
|
||||||
|
explicit=False,
|
||||||
|
date_user_added=date_user_added,
|
||||||
|
date_release=date_release,
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Hover Event Manager with Debounce Logic.
|
||||||
|
|
||||||
|
This module provides a robust hover event management system for the track list,
|
||||||
|
implementing debouncing to prevent UI flickering and excessive updates during
|
||||||
|
rapid mouse movements.
|
||||||
|
|
||||||
|
The HoverManager intercepts mouse movement events and triggers callbacks only
|
||||||
|
after the mouse has remained stationary over an item for a configurable delay.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- HoverManager: Main coordinator with debounce timer
|
||||||
|
- Event filter for capturing mouse movements
|
||||||
|
- Signal-based callbacks for decoupled communication
|
||||||
|
|
||||||
|
Performance:
|
||||||
|
- Configurable debounce delay (default: 350ms)
|
||||||
|
- Automatic cleanup of pending timers
|
||||||
|
- Thread-safe signal emissions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Mix, Playlist, Track, Video
|
||||||
|
from tidalapi.artist import Artist
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.gui import get_results_media_item
|
||||||
|
from tidal_dl_ng.logger import logger_gui
|
||||||
|
|
||||||
|
|
||||||
|
class HoverManager(QtCore.QObject):
|
||||||
|
"""Manages hover events with debouncing to prevent UI flickering.
|
||||||
|
|
||||||
|
This class implements an event filter that intercepts mouse movements over
|
||||||
|
a QTreeView and emits signals after a configurable delay to prevent rapid
|
||||||
|
updates when the user is just scanning through the list.
|
||||||
|
|
||||||
|
Signals:
|
||||||
|
s_hover_confirmed (object): Emitted when hover is confirmed after debounce delay.
|
||||||
|
s_hover_left: Emitted when mouse leaves the tracked widget.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
debounce_delay_ms (int): Delay in milliseconds before confirming hover.
|
||||||
|
tree_view (QTreeView): The tree view to monitor.
|
||||||
|
proxy_model (QSortFilterProxyModel): Proxy model for the tree view.
|
||||||
|
source_model (QStandardItemModel): Source model for the tree view.
|
||||||
|
debounce_timer (QTimer): Timer for debouncing hover events.
|
||||||
|
last_hovered_media (Track | Video | Album | None): Last media item hovered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Signals
|
||||||
|
s_hover_confirmed: QtCore.Signal = QtCore.Signal(object) # Emits media object
|
||||||
|
s_hover_left: QtCore.Signal = QtCore.Signal() # Emits when hover leaves
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tree_view: QtWidgets.QTreeView,
|
||||||
|
proxy_model: QtCore.QSortFilterProxyModel,
|
||||||
|
source_model: QtGui.QStandardItemModel,
|
||||||
|
debounce_delay_ms: int = 350,
|
||||||
|
parent: QtCore.QObject | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the HoverManager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tree_view (QTreeView): The tree view to monitor for hover events.
|
||||||
|
proxy_model (QSortFilterProxyModel): Proxy model wrapping the source model.
|
||||||
|
source_model (QStandardItemModel): Source model containing the data.
|
||||||
|
debounce_delay_ms (int, optional): Debounce delay in milliseconds. Defaults to 350.
|
||||||
|
parent (QObject | None, optional): Parent QObject. Defaults to None.
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.debounce_delay_ms: int = debounce_delay_ms
|
||||||
|
self.tree_view: QtWidgets.QTreeView = tree_view
|
||||||
|
self.proxy_model: QtCore.QSortFilterProxyModel = proxy_model
|
||||||
|
self.source_model: QtGui.QStandardItemModel = source_model
|
||||||
|
self.last_hovered_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||||
|
|
||||||
|
# Debounce timer (single-shot)
|
||||||
|
self.debounce_timer: QtCore.QTimer = QtCore.QTimer(self)
|
||||||
|
self.debounce_timer.setSingleShot(True)
|
||||||
|
self.debounce_timer.setInterval(self.debounce_delay_ms)
|
||||||
|
self.debounce_timer.timeout.connect(self._on_debounce_timeout)
|
||||||
|
|
||||||
|
# Track hover state
|
||||||
|
self.pending_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||||
|
|
||||||
|
# Install event filter on viewport (where mouse events occur)
|
||||||
|
self.tree_view.viewport().installEventFilter(self)
|
||||||
|
self.tree_view.viewport().setMouseTracking(True)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the hover manager and clean up event filters."""
|
||||||
|
try:
|
||||||
|
if self.tree_view:
|
||||||
|
self.tree_view.viewport().removeEventFilter(self)
|
||||||
|
self.tree_view = None
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
if self.timer and self.timer.isActive():
|
||||||
|
self.timer.stop()
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
||||||
|
"""Filter events to detect mouse movements over tree items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
watched (QObject): The object being watched.
|
||||||
|
event (QEvent): The event to filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if event is handled, False otherwise.
|
||||||
|
"""
|
||||||
|
if watched == self.tree_view.viewport():
|
||||||
|
if event.type() == QtCore.QEvent.Type.MouseMove:
|
||||||
|
# Cast to QMouseEvent for type safety
|
||||||
|
mouse_event = QtGui.QMouseEvent(event)
|
||||||
|
self._handle_mouse_move(mouse_event)
|
||||||
|
elif event.type() == QtCore.QEvent.Type.Leave:
|
||||||
|
self._handle_mouse_leave()
|
||||||
|
|
||||||
|
# Don't consume the event - let it propagate
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _handle_mouse_move(self, event: QtGui.QMouseEvent) -> None:
|
||||||
|
"""Handle mouse move events over the tree view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event (QMouseEvent): The mouse move event.
|
||||||
|
"""
|
||||||
|
# Get the index under the cursor
|
||||||
|
pos = event.position().toPoint()
|
||||||
|
index = self.tree_view.indexAt(pos)
|
||||||
|
|
||||||
|
if not index.isValid():
|
||||||
|
# Mouse is not over a valid item
|
||||||
|
self._cancel_pending_hover()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract media from the index
|
||||||
|
media = get_results_media_item(index, self.proxy_model, self.source_model)
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
self._cancel_pending_hover()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we're hovering over a different item
|
||||||
|
if media != self.pending_media:
|
||||||
|
# Cancel previous timer and start new one
|
||||||
|
self.pending_media = media
|
||||||
|
self.debounce_timer.stop()
|
||||||
|
self.debounce_timer.start()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger_gui.debug(f"Error extracting media from hover: {e}")
|
||||||
|
self._cancel_pending_hover()
|
||||||
|
|
||||||
|
def _handle_mouse_leave(self) -> None:
|
||||||
|
"""Handle mouse leaving the tree view."""
|
||||||
|
self._cancel_pending_hover()
|
||||||
|
self.s_hover_left.emit()
|
||||||
|
|
||||||
|
def _cancel_pending_hover(self) -> None:
|
||||||
|
"""Cancel any pending hover confirmation."""
|
||||||
|
self.debounce_timer.stop()
|
||||||
|
self.pending_media = None
|
||||||
|
|
||||||
|
def _on_debounce_timeout(self) -> None:
|
||||||
|
"""Handle debounce timer timeout - hover is confirmed."""
|
||||||
|
if self.pending_media:
|
||||||
|
self.last_hovered_media = self.pending_media
|
||||||
|
self.s_hover_confirmed.emit(self.pending_media)
|
||||||
|
# logger_gui.debug(f"Hover confirmed: {getattr(self.pending_media, 'name', 'Unknown')}")
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the hover manager state."""
|
||||||
|
self._cancel_pending_hover()
|
||||||
|
self.last_hovered_media = None
|
||||||
|
|
||||||
|
def set_debounce_delay(self, delay_ms: int) -> None:
|
||||||
|
"""Change the debounce delay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delay_ms (int): New delay in milliseconds.
|
||||||
|
"""
|
||||||
|
self.debounce_delay_ms = delay_ms
|
||||||
|
self.debounce_timer.setInterval(delay_ms)
|
||||||
|
logger_gui.debug(f"Hover debounce delay set to {delay_ms}ms")
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Utilities for extracting and formatting metadata from TIDAL objects."""
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_list_to_str(value: list | tuple) -> str:
|
||||||
|
"""Convert list/tuple to comma-separated string."""
|
||||||
|
if not value:
|
||||||
|
return "—"
|
||||||
|
with suppress(Exception):
|
||||||
|
return ", ".join([str(x) for x in value])
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_dict_to_str(value: dict) -> str:
|
||||||
|
"""Extract meaningful string from dict."""
|
||||||
|
if "name" in value and value["name"]:
|
||||||
|
return str(value["name"])
|
||||||
|
for k in ("label", "title", "genre", "name"):
|
||||||
|
if k in value and value[k]:
|
||||||
|
return str(value[k])
|
||||||
|
with suppress(Exception):
|
||||||
|
vals = [str(v) for v in value.values() if v is not None]
|
||||||
|
if vals:
|
||||||
|
return ", ".join(vals)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_str(value: object) -> str:
|
||||||
|
"""Convert a potentially non-str value into a safe string for display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The value to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Safe string representation, '—' for None/empty.
|
||||||
|
"""
|
||||||
|
with suppress(Exception):
|
||||||
|
if value is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value if value != "" else "—"
|
||||||
|
if isinstance(value, list | tuple):
|
||||||
|
return _convert_list_to_str(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return _convert_dict_to_str(value)
|
||||||
|
return str(value)
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_in_dict_container(container: dict, names: tuple[str, ...]) -> object | None:
|
||||||
|
"""Search for names in a dict container."""
|
||||||
|
for n in names:
|
||||||
|
if n in container and container[n] is not None:
|
||||||
|
return container[n]
|
||||||
|
# Fuzzy key match
|
||||||
|
keys = list(container.keys())
|
||||||
|
for n in names:
|
||||||
|
for k in keys:
|
||||||
|
if n.lower() in str(k).lower():
|
||||||
|
return container[k]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fuzzy_scan_attrs(obj: object, names: tuple[str, ...]) -> object | None:
|
||||||
|
"""Fuzzy scan object attributes for matching names."""
|
||||||
|
with suppress(Exception):
|
||||||
|
for k in dir(obj):
|
||||||
|
kl = k.lower()
|
||||||
|
for n in names:
|
||||||
|
if n.lower() in kl:
|
||||||
|
with suppress(Exception):
|
||||||
|
val = getattr(obj, k)
|
||||||
|
if val is not None:
|
||||||
|
return val
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_attr(obj: object, *names: str) -> object | None:
|
||||||
|
"""Attempt to find an attribute or data key from an object or its internals.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: The object to inspect.
|
||||||
|
*names: Attribute/key names to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The found value or None.
|
||||||
|
"""
|
||||||
|
# Direct attributes
|
||||||
|
for n in names:
|
||||||
|
with suppress(Exception):
|
||||||
|
if hasattr(obj, n):
|
||||||
|
val = getattr(obj, n)
|
||||||
|
if val is not None:
|
||||||
|
return val
|
||||||
|
|
||||||
|
# Inspect common dict-like internals
|
||||||
|
for container_name in ("_data", "data", "__dict__"):
|
||||||
|
with suppress(Exception):
|
||||||
|
container = getattr(obj, container_name, None)
|
||||||
|
if isinstance(container, dict):
|
||||||
|
result = _find_in_dict_container(container, names)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Fuzzy scan of attributes
|
||||||
|
return _fuzzy_scan_attrs(obj, names)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_dict_recursive(container: dict, key_substrings: list[str]) -> object | None:
|
||||||
|
"""Recursively scan a dict for keys matching any substring."""
|
||||||
|
for k, v in container.items():
|
||||||
|
kl = str(k).lower()
|
||||||
|
for s in key_substrings:
|
||||||
|
if s.lower() in kl and v is not None:
|
||||||
|
return v
|
||||||
|
# recurse into nested structures
|
||||||
|
if isinstance(v, dict):
|
||||||
|
found = _scan_dict_recursive(v, key_substrings)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
if isinstance(v, list | tuple):
|
||||||
|
for item in v:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
found = _scan_dict_recursive(item, key_substrings)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search_in_data(obj: object, key_substrings: list[str]) -> object | None:
|
||||||
|
"""Recursively search dict-like internals for keys containing any substring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: The object to search.
|
||||||
|
key_substrings: List of key substrings to search for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The first matching value found, or None.
|
||||||
|
"""
|
||||||
|
# check common containers
|
||||||
|
for container_name in ("_data", "data", "__dict__"):
|
||||||
|
with suppress(Exception):
|
||||||
|
container = getattr(obj, container_name, None)
|
||||||
|
if isinstance(container, dict):
|
||||||
|
found = _scan_dict_recursive(container, key_substrings)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
|
||||||
|
# as last resort, try obj.__dict__ if available
|
||||||
|
with suppress(Exception):
|
||||||
|
d = getattr(obj, "__dict__", None)
|
||||||
|
if isinstance(d, dict):
|
||||||
|
return _scan_dict_recursive(d, key_substrings)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name_from_dict(item: dict, match_types: tuple[str, ...] | None) -> str | None:
|
||||||
|
"""Extract name from a dict if it matches type filters."""
|
||||||
|
if "name" not in item or not item["name"]:
|
||||||
|
return None
|
||||||
|
if match_types:
|
||||||
|
t = item.get("type") or item.get("role") or item.get("credit_type")
|
||||||
|
if t and any(mt in str(t).lower() for mt in match_types):
|
||||||
|
return str(item["name"])
|
||||||
|
return None
|
||||||
|
return str(item["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_name_from_item(item: object) -> str | None:
|
||||||
|
"""Extract name from various item formats."""
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
if isinstance(item, str):
|
||||||
|
return item
|
||||||
|
if isinstance(item, dict):
|
||||||
|
# Try common name keys
|
||||||
|
for k in ("name", "artist", "person"):
|
||||||
|
if k in item and item[k]:
|
||||||
|
return str(item[k])
|
||||||
|
return None
|
||||||
|
# Try object attributes
|
||||||
|
nm = getattr(item, "name", None) or getattr(item, "title", None)
|
||||||
|
if nm:
|
||||||
|
return str(nm)
|
||||||
|
with suppress(Exception):
|
||||||
|
return str(item)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_names_from_mixed(value: object, match_types: tuple[str, ...] | None = None) -> list[str]:
|
||||||
|
"""Normalize various credit-like structures into a list of names.
|
||||||
|
|
||||||
|
Accepts lists of dicts, dicts, strings, or objects.
|
||||||
|
If match_types provided, only include entries where the type/role matches one of them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The value to extract names from.
|
||||||
|
match_types: Optional tuple of role types to filter by.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of extracted names.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
name = _extract_name_from_dict(value, match_types)
|
||||||
|
if name:
|
||||||
|
return [name]
|
||||||
|
vals = [str(v) for v in value.values() if v is not None]
|
||||||
|
return vals
|
||||||
|
if isinstance(value, list | tuple):
|
||||||
|
names: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
name = _extract_name_from_dict(item, match_types)
|
||||||
|
if name:
|
||||||
|
names.append(name)
|
||||||
|
else:
|
||||||
|
name = _extract_name_from_item(item)
|
||||||
|
if name:
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
return []
|
||||||
+335
-5
@@ -1,5 +1,8 @@
|
|||||||
|
import contextlib
|
||||||
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import requests
|
||||||
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
|
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
|
||||||
from tidalapi.artist import Artist, Role
|
from tidalapi.artist import Artist, Role
|
||||||
from tidalapi.media import MediaMetadataTags, Quality
|
from tidalapi.media import MediaMetadataTags, Quality
|
||||||
@@ -135,7 +138,7 @@ def search_results_all(session: Session, needle: str, types_media: SearchTypes =
|
|||||||
|
|
||||||
|
|
||||||
def items_results_all(
|
def items_results_all(
|
||||||
media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
|
session: Session, media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True
|
||||||
) -> [Track | Video | Album]:
|
) -> [Track | Video | Album]:
|
||||||
result: [Track | Video | Album] = []
|
result: [Track | Video | Album] = []
|
||||||
|
|
||||||
@@ -149,7 +152,7 @@ def items_results_all(
|
|||||||
func_get_items_media.append(media_list.items)
|
func_get_items_media.append(media_list.items)
|
||||||
else:
|
else:
|
||||||
func_get_items_media.append(media_list.tracks)
|
func_get_items_media.append(media_list.tracks)
|
||||||
else:
|
elif isinstance(media_list, Artist):
|
||||||
func_get_items_media.append(media_list.get_albums)
|
func_get_items_media.append(media_list.get_albums)
|
||||||
func_get_items_media.append(media_list.get_ep_singles)
|
func_get_items_media.append(media_list.get_ep_singles)
|
||||||
|
|
||||||
@@ -257,12 +260,20 @@ def instantiate_media(
|
|||||||
def quality_audio_highest(media: Track | Album) -> Quality:
|
def quality_audio_highest(media: Track | Album) -> Quality:
|
||||||
quality: Quality
|
quality: Quality
|
||||||
|
|
||||||
if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags:
|
# media_metadata_tags may be missing (Mock objects) or None; use safe getter
|
||||||
|
tags = getattr(media, "media_metadata_tags", None)
|
||||||
|
try:
|
||||||
|
iterable_tags = set(tags) if tags is not None else set()
|
||||||
|
except Exception:
|
||||||
|
# If tags is a Mock or non-iterable, fall back to empty set
|
||||||
|
iterable_tags = set()
|
||||||
|
|
||||||
|
if MediaMetadataTags.hi_res_lossless in iterable_tags:
|
||||||
quality = Quality.hi_res_lossless
|
quality = Quality.hi_res_lossless
|
||||||
elif MediaMetadataTags.lossless in media.media_metadata_tags:
|
elif MediaMetadataTags.lossless in iterable_tags:
|
||||||
quality = Quality.high_lossless
|
quality = Quality.high_lossless
|
||||||
else:
|
else:
|
||||||
quality = media.audio_quality
|
quality = getattr(media, "audio_quality", Quality.low_320k)
|
||||||
|
|
||||||
return quality
|
return quality
|
||||||
|
|
||||||
@@ -272,3 +283,322 @@ def favorite_function_factory(tidal, favorite_item: str):
|
|||||||
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
|
function_list: Callable = getattr(tidal.session.user.favorites, function_name)
|
||||||
|
|
||||||
return function_list
|
return function_list
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_raw_media_json(
|
||||||
|
session: Session, media_type: str, media_id: str, country_code: str | None = None, extra_params: dict | None = None
|
||||||
|
) -> dict | None:
|
||||||
|
"""Fetch raw JSON for a media resource using tidalapi's session.request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session (Session): the tidalapi Session
|
||||||
|
media_type (str): 'tracks' or 'albums'
|
||||||
|
media_id (str): id of media
|
||||||
|
country_code (str | None): optional countryCode param
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict | None: parsed JSON or None if fetch fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
# If caller didn't provide a country code, check environment variable
|
||||||
|
cc = country_code or os.environ.get("TIDAL_COUNTRY")
|
||||||
|
if cc:
|
||||||
|
params["countryCode"] = cc
|
||||||
|
# merge extra params if provided (do not overwrite existing keys unless provided)
|
||||||
|
if extra_params and isinstance(extra_params, dict):
|
||||||
|
for k, v in extra_params.items():
|
||||||
|
params[k] = v
|
||||||
|
|
||||||
|
# Use session.request.request to call the internal API endpoint
|
||||||
|
resp = session.request.request("GET", f"{media_type}/{media_id}", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.exceptions.HTTPError:
|
||||||
|
return None # Silently ignore HTTP errors
|
||||||
|
except Exception:
|
||||||
|
return None # Silently ignore other errors
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_raw_track_and_album(
|
||||||
|
session: Session,
|
||||||
|
track_id: str,
|
||||||
|
country_code: str | None = None,
|
||||||
|
extra_params: dict | None = None,
|
||||||
|
) -> tuple[dict | None, dict | None]:
|
||||||
|
"""Convenience to fetch raw track JSON and its album JSON (if available).
|
||||||
|
|
||||||
|
Returns a tuple (track_json, album_json).
|
||||||
|
|
||||||
|
Uses TIDAL API parameters to fetch extended metadata.
|
||||||
|
Note: 'credits' and 'contributors' are NOT available in TIDAL API v2.
|
||||||
|
Available include values: albums, artists, genres, lyrics, owners, providers,
|
||||||
|
radio, shares, similarTracks, sourceFile, trackStatistics
|
||||||
|
"""
|
||||||
|
# Use valid include parameters according to TIDAL API v2 spec
|
||||||
|
# https://tidal-music.github.io/tidal-api-reference/
|
||||||
|
default_track_params = {
|
||||||
|
"include": "albums,artists,genres", # Valid parameters per API spec
|
||||||
|
}
|
||||||
|
merged_track_params = {**default_track_params, **(extra_params or {})}
|
||||||
|
|
||||||
|
track_json = fetch_raw_media_json(
|
||||||
|
session,
|
||||||
|
"tracks",
|
||||||
|
str(track_id),
|
||||||
|
country_code=country_code,
|
||||||
|
extra_params=merged_track_params,
|
||||||
|
)
|
||||||
|
|
||||||
|
album_json = None
|
||||||
|
try:
|
||||||
|
if isinstance(track_json, dict):
|
||||||
|
album = track_json.get("album")
|
||||||
|
album_id = album.get("id") if isinstance(album, dict) else album
|
||||||
|
if album_id:
|
||||||
|
# Request extended album metadata
|
||||||
|
default_album_params = {
|
||||||
|
"include": "artists,genres", # Valid parameters for albums
|
||||||
|
}
|
||||||
|
merged_album_params = {**default_album_params, **(extra_params or {})}
|
||||||
|
|
||||||
|
album_json = fetch_raw_media_json(
|
||||||
|
session,
|
||||||
|
"albums",
|
||||||
|
str(album_id),
|
||||||
|
country_code=country_code,
|
||||||
|
extra_params=merged_album_params,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
album_json = None
|
||||||
|
|
||||||
|
return track_json, album_json
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_dict_contributors(raw_contributors: dict) -> dict[str, list[str]]:
|
||||||
|
"""Process contributors in dict format: role -> list[{name, ...}]."""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for role, people in raw_contributors.items():
|
||||||
|
if not isinstance(people, list):
|
||||||
|
continue
|
||||||
|
names: list[str] = []
|
||||||
|
for person in people:
|
||||||
|
if isinstance(person, dict):
|
||||||
|
name = person.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
names.append(name)
|
||||||
|
if names:
|
||||||
|
result[role] = names
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_list_contributors(raw_contributors: list) -> dict[str, list[str]]:
|
||||||
|
"""Process contributors in list format: [{name, role, ...}, ...]."""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for person in raw_contributors:
|
||||||
|
if not isinstance(person, dict):
|
||||||
|
continue
|
||||||
|
name = person.get("name")
|
||||||
|
role = person.get("role")
|
||||||
|
if isinstance(name, str) and name and isinstance(role, str) and role:
|
||||||
|
result.setdefault(role, []).append(name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contributors(raw_contributors: object) -> dict[str, list[str]]:
|
||||||
|
"""Normalize various possible contributor JSON shapes into role -> list[str] names.
|
||||||
|
|
||||||
|
The TIDAL API has used at least two shapes historically:
|
||||||
|
- dict role -> list[ {"name": str, ...} ]
|
||||||
|
- list[ {"name": str, "role": str, ...} ]
|
||||||
|
|
||||||
|
We accept both and ignore malformed entries.
|
||||||
|
"""
|
||||||
|
if isinstance(raw_contributors, dict):
|
||||||
|
return _normalize_dict_contributors(raw_contributors)
|
||||||
|
if isinstance(raw_contributors, list):
|
||||||
|
return _normalize_list_contributors(raw_contributors)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bpm_from_track(track_json: dict) -> int | None:
|
||||||
|
"""Extract BPM from track JSON."""
|
||||||
|
bpm = track_json.get("bpm")
|
||||||
|
if isinstance(bpm, int | float):
|
||||||
|
return int(round(bpm))
|
||||||
|
if isinstance(bpm, str):
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
return int(round(float(bpm)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _process_credits_contributors(credits_list: list) -> dict[str, list[str]]:
|
||||||
|
"""Process credits API v2 format and return contributors by role."""
|
||||||
|
role_mapping = {
|
||||||
|
"producer": "producer",
|
||||||
|
"producers": "producer",
|
||||||
|
"composer": "composer",
|
||||||
|
"composers": "composer",
|
||||||
|
"lyricist": "lyricist",
|
||||||
|
"lyricists": "lyricist",
|
||||||
|
"writer": "composer",
|
||||||
|
"writers": "composer",
|
||||||
|
}
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for credit in credits_list:
|
||||||
|
if not isinstance(credit, dict):
|
||||||
|
continue
|
||||||
|
credit_type = credit.get("type", "").lower()
|
||||||
|
contributors = credit.get("contributors", [])
|
||||||
|
role = role_mapping.get(credit_type, credit_type)
|
||||||
|
if isinstance(contributors, list):
|
||||||
|
for contributor in contributors:
|
||||||
|
if isinstance(contributor, dict):
|
||||||
|
name = contributor.get("name")
|
||||||
|
if name:
|
||||||
|
result.setdefault(role, []).append(name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_track_contributors(track_json: dict) -> dict[str, list[str]]:
|
||||||
|
"""Extract contributors from track JSON."""
|
||||||
|
# Try credits first (API v2)
|
||||||
|
track_credits = track_json.get("credits")
|
||||||
|
if track_credits and isinstance(track_credits, list):
|
||||||
|
contributors = _process_credits_contributors(track_credits)
|
||||||
|
if contributors:
|
||||||
|
return contributors
|
||||||
|
# Fallback to old format
|
||||||
|
raw_contributors = track_json.get("contributors")
|
||||||
|
if raw_contributors:
|
||||||
|
return _normalize_contributors(raw_contributors)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _process_genre_item(g: object) -> str | None:
|
||||||
|
"""Extract genre name from various formats."""
|
||||||
|
if isinstance(g, str) and g:
|
||||||
|
return g
|
||||||
|
if isinstance(g, dict):
|
||||||
|
name = g.get("name")
|
||||||
|
if isinstance(name, str) and name:
|
||||||
|
return name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_genres(genres: list[str]) -> list[str]:
|
||||||
|
"""Deduplicate genres while preserving order."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique: list[str] = []
|
||||||
|
for g in genres:
|
||||||
|
if g not in seen:
|
||||||
|
seen.add(g)
|
||||||
|
unique.append(g)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_album_label_genres(album_json: dict) -> tuple[str, list[str]]:
|
||||||
|
"""Extract label and genres from album JSON."""
|
||||||
|
# Label
|
||||||
|
label = album_json.get("label") or album_json.get("recordLabel")
|
||||||
|
label_str = label if isinstance(label, str) else ""
|
||||||
|
|
||||||
|
# Genres
|
||||||
|
raw_genres = album_json.get("genres") or album_json.get("genre")
|
||||||
|
genres: list[str] = []
|
||||||
|
|
||||||
|
if isinstance(raw_genres, list):
|
||||||
|
for g in raw_genres:
|
||||||
|
genre = _process_genre_item(g)
|
||||||
|
if genre:
|
||||||
|
genres.append(genre)
|
||||||
|
elif isinstance(raw_genres, str) and raw_genres:
|
||||||
|
genres.append(raw_genres)
|
||||||
|
else:
|
||||||
|
genre = _process_genre_item(raw_genres)
|
||||||
|
if genre:
|
||||||
|
genres.append(genre)
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
if genres:
|
||||||
|
return label_str, _deduplicate_genres(genres)
|
||||||
|
return label_str, []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_album_contributors(album_json: dict) -> dict[str, list[str]]:
|
||||||
|
"""Extract contributors from album JSON."""
|
||||||
|
# Try credits first (API v2)
|
||||||
|
album_credits = album_json.get("credits")
|
||||||
|
if album_credits and isinstance(album_credits, list):
|
||||||
|
contributors = _process_credits_contributors(album_credits)
|
||||||
|
if contributors:
|
||||||
|
return contributors
|
||||||
|
# Fallback to old format
|
||||||
|
raw_contributors = album_json.get("contributors")
|
||||||
|
if raw_contributors:
|
||||||
|
return _normalize_contributors(raw_contributors)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_track_and_album_extras(
|
||||||
|
track_json: dict | None,
|
||||||
|
album_json: dict | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Extract extra metadata from raw TIDAL JSON for a track and its album.
|
||||||
|
|
||||||
|
Returned dict keys (all optional, may be missing or empty):
|
||||||
|
- bpm: int | None
|
||||||
|
- label: str
|
||||||
|
- genres: list[str]
|
||||||
|
- contributors_by_role: dict[str, list[str]]
|
||||||
|
"""
|
||||||
|
|
||||||
|
extras: dict = {
|
||||||
|
"bpm": None,
|
||||||
|
"label": "",
|
||||||
|
"genres": [],
|
||||||
|
"contributors_by_role": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract from track
|
||||||
|
if isinstance(track_json, dict):
|
||||||
|
extras["bpm"] = _extract_bpm_from_track(track_json)
|
||||||
|
extras["contributors_by_role"] = _extract_track_contributors(track_json)
|
||||||
|
|
||||||
|
# Extract from album
|
||||||
|
if isinstance(album_json, dict):
|
||||||
|
label, genres = _extract_album_label_genres(album_json)
|
||||||
|
extras["label"] = label
|
||||||
|
extras["genres"] = genres
|
||||||
|
|
||||||
|
# If we did not get track-level contributors, try album-level
|
||||||
|
if not extras["contributors_by_role"]:
|
||||||
|
extras["contributors_by_role"] = _extract_album_contributors(album_json)
|
||||||
|
|
||||||
|
return extras
|
||||||
|
|
||||||
|
|
||||||
|
def extract_contributor_names(
|
||||||
|
contributors_by_role: dict[str, list[str]] | None,
|
||||||
|
role: str,
|
||||||
|
delimiter: str = ", ",
|
||||||
|
) -> str:
|
||||||
|
"""Return a delimited string of contributor names for a given role.
|
||||||
|
|
||||||
|
If the role is not present or has no names, returns an empty string.
|
||||||
|
Role matching is case-insensitive.
|
||||||
|
"""
|
||||||
|
if not contributors_by_role:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Normalise keys to lowercase for robust lookups.
|
||||||
|
role_lc = role.lower()
|
||||||
|
for r, names in contributors_by_role.items():
|
||||||
|
if not isinstance(r, str):
|
||||||
|
continue
|
||||||
|
if r.lower() == role_lc and isinstance(names, list):
|
||||||
|
filtered = [n for n in names if isinstance(n, str) and n]
|
||||||
|
if filtered:
|
||||||
|
return delimiter.join(filtered)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|||||||
+65
-4
@@ -32,6 +32,13 @@ class Metadata:
|
|||||||
upc: str
|
upc: str
|
||||||
target_upc: dict[str, str]
|
target_upc: dict[str, str]
|
||||||
explicit: bool
|
explicit: bool
|
||||||
|
# New enriched metadata fields
|
||||||
|
genre: str
|
||||||
|
label: str
|
||||||
|
bpm: int | None
|
||||||
|
producers: str
|
||||||
|
composers_detailed: str
|
||||||
|
lyricists: str
|
||||||
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
|
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -61,6 +68,13 @@ class Metadata:
|
|||||||
replay_gain_write: bool = True,
|
replay_gain_write: bool = True,
|
||||||
upc: str = "",
|
upc: str = "",
|
||||||
explicit: bool = False,
|
explicit: bool = False,
|
||||||
|
# New enriched metadata kwargs (all optional)
|
||||||
|
genre: str = "",
|
||||||
|
label: str = "",
|
||||||
|
bpm: int | None = None,
|
||||||
|
producers: str = "",
|
||||||
|
composers_detailed: str = "",
|
||||||
|
lyricists: str = "",
|
||||||
):
|
):
|
||||||
self.path_file = path_file
|
self.path_file = path_file
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -87,6 +101,13 @@ class Metadata:
|
|||||||
self.upc = upc
|
self.upc = upc
|
||||||
self.target_upc = target_upc
|
self.target_upc = target_upc
|
||||||
self.explicit = explicit
|
self.explicit = explicit
|
||||||
|
# Store enriched metadata
|
||||||
|
self.genre = genre
|
||||||
|
self.label = label
|
||||||
|
self.bpm = bpm
|
||||||
|
self.producers = producers
|
||||||
|
self.composers_detailed = composers_detailed
|
||||||
|
self.lyricists = lyricists
|
||||||
self.m: mutagen.FileType = mutagen.File(self.path_file)
|
self.m: mutagen.FileType = mutagen.File(self.path_file)
|
||||||
|
|
||||||
def _cover(self) -> bool:
|
def _cover(self) -> bool:
|
||||||
@@ -139,12 +160,24 @@ class Metadata:
|
|||||||
self.m.tags["DISCNUMBER"] = str(self.discnumber)
|
self.m.tags["DISCNUMBER"] = str(self.discnumber)
|
||||||
self.m.tags["DISCTOTAL"] = str(self.totaldisc)
|
self.m.tags["DISCTOTAL"] = str(self.totaldisc)
|
||||||
self.m.tags["DATE"] = self.date
|
self.m.tags["DATE"] = self.date
|
||||||
self.m.tags["COMPOSER"] = self.composer
|
self.m.tags["COMPOSER"] = self.composer if self.composer else self.composers_detailed
|
||||||
self.m.tags["ISRC"] = self.isrc
|
self.m.tags["ISRC"] = self.isrc
|
||||||
self.m.tags["LYRICS"] = self.lyrics
|
self.m.tags["LYRICS"] = self.lyrics
|
||||||
self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced
|
self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced
|
||||||
self.m.tags["URL"] = self.url_share
|
self.m.tags["URL"] = self.url_share
|
||||||
self.m.tags[self.target_upc["FLAC"]] = self.upc
|
self.m.tags[self.target_upc["FLAC"]] = self.upc
|
||||||
|
# Enriched fields
|
||||||
|
if self.genre:
|
||||||
|
self.m.tags["GENRE"] = self.genre
|
||||||
|
if self.label:
|
||||||
|
# Using LABEL; some tools map PUBLISHER, but LABEL is widely recognised in Vorbis.
|
||||||
|
self.m.tags["LABEL"] = self.label
|
||||||
|
if self.bpm is not None:
|
||||||
|
self.m.tags["BPM"] = str(self.bpm)
|
||||||
|
if self.producers:
|
||||||
|
self.m.tags["PRODUCER"] = self.producers
|
||||||
|
if self.lyricists:
|
||||||
|
self.m.tags["LYRICIST"] = self.lyricists
|
||||||
|
|
||||||
if self.replay_gain_write:
|
if self.replay_gain_write:
|
||||||
self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain)
|
self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain)
|
||||||
@@ -163,12 +196,29 @@ class Metadata:
|
|||||||
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
|
self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
|
||||||
self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
|
self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
|
||||||
self.m.tags.add(TDRC(encoding=3, text=self.date))
|
self.m.tags.add(TDRC(encoding=3, text=self.date))
|
||||||
self.m.tags.add(TCOM(encoding=3, text=self.composer))
|
self.m.tags.add(TCOM(encoding=3, text=self.composer if self.composer else self.composers_detailed))
|
||||||
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
|
self.m.tags.add(TSRC(encoding=3, text=self.isrc))
|
||||||
self.m.tags.add(SYLT(encoding=3, desc="text", text=self.lyrics))
|
self.m.tags.add(SYLT(encoding=3, desc="text", text=self.lyrics))
|
||||||
self.m.tags.add(USLT(encoding=3, desc="text", text=self.lyrics_unsynced))
|
self.m.tags.add(USLT(encoding=3, desc="text", text=self.lyrics_unsynced))
|
||||||
self.m.tags.add(WOAS(encoding=3, text=self.isrc))
|
self.m.tags.add(WOAS(encoding=3, text=self.isrc))
|
||||||
self.m.tags.add(TXXX(encoding=3, desc=self.target_upc["MP3"], text=self.upc))
|
self.m.tags.add(TXXX(encoding=3, desc=self.target_upc["MP3"], text=self.upc))
|
||||||
|
# Enriched fields
|
||||||
|
if self.genre:
|
||||||
|
from mutagen.id3 import TCON
|
||||||
|
|
||||||
|
self.m.tags.add(TCON(encoding=3, text=self.genre))
|
||||||
|
if self.label:
|
||||||
|
from mutagen.id3 import TPUB
|
||||||
|
|
||||||
|
self.m.tags.add(TPUB(encoding=3, text=self.label))
|
||||||
|
if self.bpm is not None:
|
||||||
|
from mutagen.id3 import TBPM
|
||||||
|
|
||||||
|
self.m.tags.add(TBPM(encoding=3, text=str(self.bpm)))
|
||||||
|
if self.producers:
|
||||||
|
self.m.tags.add(TXXX(encoding=3, desc="PRODUCER", text=self.producers))
|
||||||
|
if self.lyricists:
|
||||||
|
self.m.tags.add(TXXX(encoding=3, desc="LYRICIST", text=self.lyricists))
|
||||||
|
|
||||||
if self.replay_gain_write:
|
if self.replay_gain_write:
|
||||||
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain)))
|
self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain)))
|
||||||
@@ -184,15 +234,26 @@ class Metadata:
|
|||||||
self.m.tags["cprt"] = self.copy_right
|
self.m.tags["cprt"] = self.copy_right
|
||||||
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
|
self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
|
||||||
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
|
self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
|
||||||
# self.m.tags['\xa9gen'] = self.genre
|
if self.genre:
|
||||||
|
self.m.tags["\xa9gen"] = self.genre
|
||||||
self.m.tags["\xa9day"] = self.date
|
self.m.tags["\xa9day"] = self.date
|
||||||
self.m.tags["\xa9wrt"] = self.composer
|
self.m.tags["\xa9wrt"] = self.composer if self.composer else self.composers_detailed
|
||||||
self.m.tags["\xa9lyr"] = self.lyrics
|
self.m.tags["\xa9lyr"] = self.lyrics
|
||||||
self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8")
|
self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8")
|
||||||
self.m.tags["isrc"] = self.isrc
|
self.m.tags["isrc"] = self.isrc
|
||||||
self.m.tags["\xa9url"] = self.url_share
|
self.m.tags["\xa9url"] = self.url_share
|
||||||
self.m.tags[f"----:com.apple.iTunes:{self.target_upc['MP4']}"] = self.upc.encode("utf-8")
|
self.m.tags[f"----:com.apple.iTunes:{self.target_upc['MP4']}"] = self.upc.encode("utf-8")
|
||||||
self.m.tags["rtng"] = [1 if self.explicit else 0]
|
self.m.tags["rtng"] = [1 if self.explicit else 0]
|
||||||
|
# Custom iTunes free-form tags for label / credits
|
||||||
|
if self.label:
|
||||||
|
self.m.tags["----:com.apple.iTunes:LABEL"] = self.label.encode("utf-8")
|
||||||
|
if self.producers:
|
||||||
|
self.m.tags["----:com.apple.iTunes:PRODUCER"] = self.producers.encode("utf-8")
|
||||||
|
if self.lyricists:
|
||||||
|
self.m.tags["----:com.apple.iTunes:LYRICIST"] = self.lyricists.encode("utf-8")
|
||||||
|
if self.bpm is not None:
|
||||||
|
# Standard MP4 tempo atom
|
||||||
|
self.m.tags["tmpo"] = [int(self.bpm)]
|
||||||
|
|
||||||
if self.replay_gain_write:
|
if self.replay_gain_write:
|
||||||
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8")
|
self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,883 @@
|
|||||||
|
"""Info Tab Widget - Dynamic tabbed information panel for track hover preview.
|
||||||
|
|
||||||
|
This module provides a sophisticated tabbed widget that displays rich metadata
|
||||||
|
for TIDAL media items on hover, implementing a debounced event system to prevent
|
||||||
|
UI flickering and unnecessary API calls.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- InfoTabWidget: Main container with two tabs (Details + Cover Art)
|
||||||
|
- DebounceTimer: Configurable timer to prevent event flooding
|
||||||
|
- TrackInfoFormatter: Utility class to format track metadata consistently
|
||||||
|
|
||||||
|
Performance Considerations:
|
||||||
|
- Pre-loaded metadata to avoid API chattiness
|
||||||
|
- Debounced hover events (300-400ms delay)
|
||||||
|
- Thread-safe signal emissions for cross-thread updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from PySide6 import QtCore, QtGui, QtWidgets
|
||||||
|
from tidalapi import Album, Mix, Playlist, Track, Video
|
||||||
|
from tidalapi.artist import Artist
|
||||||
|
from tidalapi.media import AudioMode
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.metadata_utils import extract_names_from_mixed, find_attr, safe_str
|
||||||
|
from tidal_dl_ng.helper.path import resource_path
|
||||||
|
from tidal_dl_ng.helper.tidal import name_builder_artist, name_builder_title, quality_audio_highest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TrackInfoFormatter:
|
||||||
|
"""Formats track metadata into human-readable strings.
|
||||||
|
|
||||||
|
This utility class handles the conversion of raw TIDAL API objects into
|
||||||
|
formatted display strings for the UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_artist_name(artist: object) -> str:
|
||||||
|
"""Safely get artist name from artist object."""
|
||||||
|
name = getattr(artist, "name", None)
|
||||||
|
if name is None:
|
||||||
|
with suppress(Exception):
|
||||||
|
return str(artist)
|
||||||
|
return "Unknown"
|
||||||
|
return str(name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_artist_roles(artist: object) -> list[str]:
|
||||||
|
"""Extract and format artist roles."""
|
||||||
|
if not hasattr(artist, "roles") or not artist.roles:
|
||||||
|
return []
|
||||||
|
roles = []
|
||||||
|
for role in artist.roles:
|
||||||
|
rname = getattr(role, "name", None)
|
||||||
|
if rname is None:
|
||||||
|
with suppress(Exception):
|
||||||
|
rname = str(role)
|
||||||
|
if rname:
|
||||||
|
roles.append(str(rname))
|
||||||
|
return roles
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_artists(media: Track | Video | Album) -> str:
|
||||||
|
"""Format artist names with roles if available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media (Track | Video | Album): The media object with artist information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted artist string (e.g., "Artist1, Artist2 (Feature)").
|
||||||
|
"""
|
||||||
|
with suppress(Exception):
|
||||||
|
artist_parts: list[str] = []
|
||||||
|
artists = getattr(media, "artists", [])
|
||||||
|
|
||||||
|
for artist in artists:
|
||||||
|
name = TrackInfoFormatter._get_artist_name(artist)
|
||||||
|
roles = TrackInfoFormatter._format_artist_roles(artist)
|
||||||
|
|
||||||
|
if roles and roles[0] != "main":
|
||||||
|
name = f"{name} ({', '.join(roles)})"
|
||||||
|
artist_parts.append(name)
|
||||||
|
|
||||||
|
if artist_parts:
|
||||||
|
return ", ".join(artist_parts)
|
||||||
|
|
||||||
|
return name_builder_artist(media) if hasattr(media, "artists") else "Unknown Artist"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_codec(track: Track) -> str:
|
||||||
|
"""Format codec information including audio mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track (Track): The track object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted codec string (e.g., "FLAC / Dolby Atmos").
|
||||||
|
"""
|
||||||
|
codec_parts: list[str] = []
|
||||||
|
|
||||||
|
# If this is a Video object, prefer video_quality (e.g., '1080p')
|
||||||
|
if hasattr(track, "video_quality"):
|
||||||
|
with suppress(Exception):
|
||||||
|
return str(track.video_quality)
|
||||||
|
|
||||||
|
# Get base quality for audio tracks
|
||||||
|
quality = quality_audio_highest(track)
|
||||||
|
if quality:
|
||||||
|
codec_parts.append(str(quality).upper())
|
||||||
|
|
||||||
|
# Check for Dolby Atmos
|
||||||
|
if hasattr(track, "audio_modes") and track.audio_modes and AudioMode.dolby_atmos.value in track.audio_modes:
|
||||||
|
codec_parts.append("Dolby Atmos")
|
||||||
|
|
||||||
|
# Check for MQA
|
||||||
|
if (
|
||||||
|
hasattr(track, "media_metadata_tags")
|
||||||
|
and track.media_metadata_tags
|
||||||
|
and "MQA" in str(track.media_metadata_tags)
|
||||||
|
):
|
||||||
|
codec_parts.append("MQA")
|
||||||
|
|
||||||
|
return " / ".join(codec_parts) if codec_parts else "N/A"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_duration(duration_sec: int | None) -> str:
|
||||||
|
"""Format duration in seconds to MM:SS format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration_sec (int | None): Duration in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted duration string.
|
||||||
|
"""
|
||||||
|
# Treat 0 as a valid duration (00:00); only None or missing -> N/A
|
||||||
|
if duration_sec is None:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
minutes = duration_sec // 60
|
||||||
|
seconds = duration_sec % 60
|
||||||
|
return f"{minutes:02d}:{seconds:02d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_date(date_obj) -> str:
|
||||||
|
"""Format date object to readable string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_obj: Date object (datetime or similar).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted date string (YYYY-MM-DD) or N/A.
|
||||||
|
"""
|
||||||
|
if date_obj is None:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Only call strftime on actual date/datetime objects to avoid
|
||||||
|
# mocks or unrelated objects having a 'strftime' attribute.
|
||||||
|
if isinstance(date_obj, datetime.date | datetime.datetime):
|
||||||
|
return date_obj.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# If it's already a string, return as-is
|
||||||
|
if isinstance(date_obj, str):
|
||||||
|
return date_obj
|
||||||
|
|
||||||
|
# Fallback to string conversion
|
||||||
|
return str(date_obj)
|
||||||
|
except Exception:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_bitrate(track: Track) -> str:
|
||||||
|
"""Format bitrate information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track (Track): The track object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Formatted bitrate string.
|
||||||
|
"""
|
||||||
|
# Check for bit depth and sample rate
|
||||||
|
bit_depth = getattr(track, "bit_depth", None)
|
||||||
|
sample_rate = getattr(track, "sample_rate", None)
|
||||||
|
|
||||||
|
if bit_depth and sample_rate:
|
||||||
|
return f"{bit_depth}-bit / {sample_rate / 1000:.1f} kHz"
|
||||||
|
elif sample_rate:
|
||||||
|
return f"{sample_rate / 1000:.1f} kHz"
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
|
||||||
|
class InfoTabWidget(QtCore.QObject):
|
||||||
|
"""Controller for the tabbed widget displaying rich media information.
|
||||||
|
|
||||||
|
This class manages the logic for the info tabs but is not a widget itself.
|
||||||
|
It binds to existing widgets created from a .ui file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Signals for cross-thread communication
|
||||||
|
s_update_details: QtCore.Signal = QtCore.Signal(object)
|
||||||
|
s_update_cover: QtCore.Signal = QtCore.Signal(str)
|
||||||
|
s_spinner_start: QtCore.Signal = QtCore.Signal()
|
||||||
|
s_spinner_stop: QtCore.Signal = QtCore.Signal()
|
||||||
|
s_search_in_app: QtCore.Signal = QtCore.Signal(str, str)
|
||||||
|
s_search_in_browser: QtCore.Signal = QtCore.Signal(str, str)
|
||||||
|
|
||||||
|
def _setup_search_roots(self, parent_widget: QtWidgets.QTabWidget | None) -> list:
|
||||||
|
"""Setup list of widget roots to search for UI elements."""
|
||||||
|
search_roots = [parent_widget]
|
||||||
|
if hasattr(parent_widget, "parent") and parent_widget.parent() is not None:
|
||||||
|
search_roots.append(parent_widget.parent())
|
||||||
|
with suppress(Exception):
|
||||||
|
root_window = parent_widget.window()
|
||||||
|
if root_window is not None:
|
||||||
|
search_roots.append(root_window)
|
||||||
|
return search_roots
|
||||||
|
|
||||||
|
def _find_widget_in_roots(self, objname: str, search_roots: list) -> QtWidgets.QLabel | None:
|
||||||
|
"""Find a QLabel widget by objectName in search roots."""
|
||||||
|
for root in search_roots:
|
||||||
|
with suppress(Exception):
|
||||||
|
found = root.findChild(QtWidgets.QLabel, objname)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_fallback_widget(self, desc: str, parent_widget: QtWidgets.QWidget) -> tuple[str, QtWidgets.QLabel]:
|
||||||
|
"""Create a fallback invisible widget for a missing UI element."""
|
||||||
|
name = desc.split("(")[-1].rstrip(")") if "(" in desc and ")" in desc else f"fallback_{desc.replace(' ', '_')}"
|
||||||
|
|
||||||
|
fallback = QtWidgets.QLabel("—", parent_widget)
|
||||||
|
fallback.setObjectName(name)
|
||||||
|
fallback.setVisible(False)
|
||||||
|
return name, fallback
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QtWidgets.QWidget | None = None,
|
||||||
|
existing_tab_widget: QtWidgets.QTabWidget | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the InfoTabWidget controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QWidget | None): Parent widget. Defaults to None.
|
||||||
|
existing_tab_widget (QTabWidget | None): The tab widget from the UI to control.
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
# State management
|
||||||
|
self.current_media_hovered: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||||
|
self.current_media_selected: Track | Video | Album | Mix | Playlist | Artist | None = None
|
||||||
|
self.cover_url_current: str = ""
|
||||||
|
self._track_extras_provider: Callable[[str], dict] | None = None
|
||||||
|
|
||||||
|
# Bind to widgets created in the .ui by exact objectName.
|
||||||
|
self.tab_widget = existing_tab_widget
|
||||||
|
parent_widget = self.tab_widget
|
||||||
|
|
||||||
|
# REQUIRED object names provided by main.ui
|
||||||
|
required = {
|
||||||
|
"lbl_title": "Title label (lbl_title)",
|
||||||
|
"lbl_version": "Version label (lbl_version)",
|
||||||
|
"lbl_artists": "Artists label (lbl_artists)",
|
||||||
|
"lbl_album": "Album label (lbl_album)",
|
||||||
|
"lbl_codec": "Codec label (lbl_codec)",
|
||||||
|
"lbl_bitrate": "Bitrate label (lbl_bitrate)",
|
||||||
|
"lbl_duration": "Duration label (lbl_duration)",
|
||||||
|
"lbl_release_date": "Release date label (lbl_release_date)",
|
||||||
|
"lbl_popularity": "Popularity label (lbl_popularity)",
|
||||||
|
"lbl_bpm": "BPM label (lbl_bpm)",
|
||||||
|
"lbl_isrc": "ISRC label (lbl_isrc)",
|
||||||
|
"lbl_track_number": "Track number label (lbl_track_number)",
|
||||||
|
"l_pm_cover": "Cover QLabel (l_pm_cover)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lookup and assign
|
||||||
|
search_roots = self._setup_search_roots(parent_widget)
|
||||||
|
missing = []
|
||||||
|
|
||||||
|
for objname, desc in required.items():
|
||||||
|
found_widget = self._find_widget_in_roots(objname, search_roots)
|
||||||
|
if not found_widget:
|
||||||
|
missing.append(desc)
|
||||||
|
else:
|
||||||
|
attr_name = "cover_label" if objname == "l_pm_cover" else objname
|
||||||
|
setattr(self, attr_name, found_widget)
|
||||||
|
|
||||||
|
# Create fallback widgets for missing elements
|
||||||
|
if missing:
|
||||||
|
for desc in missing:
|
||||||
|
name, fallback = self._create_fallback_widget(desc, parent_widget)
|
||||||
|
if name == "l_pm_cover":
|
||||||
|
self.cover_label = fallback
|
||||||
|
else:
|
||||||
|
setattr(self, name, fallback)
|
||||||
|
|
||||||
|
# Ensure cover_label exists
|
||||||
|
if not hasattr(self, "cover_label"):
|
||||||
|
self.cover_label = QtWidgets.QLabel("", parent_widget)
|
||||||
|
self.cover_label.setVisible(False)
|
||||||
|
self.cover_label.setObjectName("cover_label_fallback")
|
||||||
|
|
||||||
|
# connect signals
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
# Make labels clickable using linkActivated signal
|
||||||
|
clickable_labels = [self.lbl_title, self.lbl_artists, self.lbl_album]
|
||||||
|
for label in clickable_labels:
|
||||||
|
label.setTextFormat(QtCore.Qt.RichText)
|
||||||
|
label.setOpenExternalLinks(False) # We handle links manually
|
||||||
|
label.linkActivated.connect(self._on_link_activated)
|
||||||
|
label.installEventFilter(self) # For right-click handling
|
||||||
|
label.setToolTip("Left-click to search in app, Ctrl+click or right-click to search in browser.")
|
||||||
|
|
||||||
|
def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
||||||
|
"""Filter events for clickable labels to handle right-clicks."""
|
||||||
|
if event.type() == QtCore.QEvent.Type.MouseButtonPress and isinstance(watched, QtWidgets.QLabel):
|
||||||
|
event: QtGui.QMouseEvent
|
||||||
|
if event.button() == QtCore.Qt.MouseButton.RightButton:
|
||||||
|
# On right-click, find the link under the cursor
|
||||||
|
anchor = watched.anchorAt(event.pos())
|
||||||
|
if anchor:
|
||||||
|
self._on_link_activated(anchor, right_click=True)
|
||||||
|
return True # Event handled
|
||||||
|
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
|
def _create_link(self, search_type: str, text: str) -> str:
|
||||||
|
"""Create an HTML link for search, using a specific color."""
|
||||||
|
if not text or text == "—":
|
||||||
|
return "—"
|
||||||
|
# The link format is "search:type:text"
|
||||||
|
link_target = f"search:{search_type}:{text}"
|
||||||
|
# Use a standard link color; Qt will handle styling.
|
||||||
|
return f"<a href='{link_target}' style='color:#367af6;'>{text}</a>"
|
||||||
|
|
||||||
|
@QtCore.Slot(str)
|
||||||
|
def _on_link_activated(self, link: str, right_click: bool = False) -> None:
|
||||||
|
"""Handle clicks on rich text links in labels."""
|
||||||
|
if not link:
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = link.split(":", 2)
|
||||||
|
if len(parts) < 3 or parts[0] != "search":
|
||||||
|
return
|
||||||
|
|
||||||
|
search_type = parts[1]
|
||||||
|
search_term = parts[2]
|
||||||
|
modifiers = QtWidgets.QApplication.keyboardModifiers()
|
||||||
|
|
||||||
|
is_browser_action = right_click or (modifiers & QtCore.Qt.KeyboardModifier.ControlModifier)
|
||||||
|
|
||||||
|
if is_browser_action:
|
||||||
|
# Ctrl+Click or Right-Click: Open in browser
|
||||||
|
self.s_search_in_browser.emit(search_term, search_type)
|
||||||
|
else:
|
||||||
|
# Left-Click: Search in app
|
||||||
|
self.s_search_in_app.emit(search_term, search_type)
|
||||||
|
|
||||||
|
def _connect_signals(self) -> None:
|
||||||
|
"""Connect internal signals to slots."""
|
||||||
|
self.s_update_details.connect(self._on_update_details)
|
||||||
|
self.s_update_cover.connect(self._on_update_cover)
|
||||||
|
self.s_spinner_start.connect(self._spin_start)
|
||||||
|
self.s_spinner_stop.connect(self._spin_stop)
|
||||||
|
|
||||||
|
def _on_update_cover(self, cover_url: str) -> None:
|
||||||
|
"""Update cover image from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cover_url (str): URL or path to the cover image.
|
||||||
|
"""
|
||||||
|
if cover_url and cover_url != self.cover_url_current:
|
||||||
|
self.cover_url_current = cover_url
|
||||||
|
with suppress(Exception):
|
||||||
|
self.set_cover_pixmap(cover_url)
|
||||||
|
|
||||||
|
def set_track_extras_provider(
|
||||||
|
self, provider: Callable[[str, Callable[[str, dict | None], None]], dict | None] | None
|
||||||
|
) -> None:
|
||||||
|
"""Register a callable that fetches track extras asynchronously.
|
||||||
|
|
||||||
|
The provider must accept ``track_id`` and a callback; it may return
|
||||||
|
immediate extras synchronously. Passing ``None`` removes the provider.
|
||||||
|
"""
|
||||||
|
self._track_extras_provider = provider
|
||||||
|
|
||||||
|
def set_cover_pixmap(self, content: QtGui.QPixmap | str) -> None:
|
||||||
|
"""Set the cover image pixmap or path."""
|
||||||
|
with suppress(Exception):
|
||||||
|
if isinstance(content, QtGui.QPixmap):
|
||||||
|
self.cover_label.setPixmap(content)
|
||||||
|
elif isinstance(content, str) and content:
|
||||||
|
pixmap = QtGui.QPixmap()
|
||||||
|
if pixmap.load(content):
|
||||||
|
self.cover_label.setPixmap(pixmap)
|
||||||
|
|
||||||
|
def _spin_start(self) -> None:
|
||||||
|
"""Track nested spinner requests so UI stays responsive."""
|
||||||
|
count = getattr(self, "_spinner_depth", 0) + 1
|
||||||
|
self._spinner_depth = count
|
||||||
|
parent = self.parent()
|
||||||
|
if count == 1 and parent and isinstance(parent, QtWidgets.QWidget):
|
||||||
|
parent.setCursor(QtCore.Qt.CursorShape.BusyCursor)
|
||||||
|
|
||||||
|
def _spin_stop(self) -> None:
|
||||||
|
"""Release spinner state when last request finishes."""
|
||||||
|
count = max(getattr(self, "_spinner_depth", 1) - 1, 0)
|
||||||
|
self._spinner_depth = count
|
||||||
|
parent = self.parent()
|
||||||
|
if count == 0 and parent and isinstance(parent, QtWidgets.QWidget):
|
||||||
|
parent.setCursor(QtCore.Qt.CursorShape.ArrowCursor)
|
||||||
|
|
||||||
|
def _request_track_extras_if_needed(self, media: object) -> None:
|
||||||
|
track_id = getattr(media, "id", None)
|
||||||
|
if not isinstance(track_id, int | str):
|
||||||
|
return
|
||||||
|
if not isinstance(media, Track):
|
||||||
|
return
|
||||||
|
if self._track_extras_provider is None:
|
||||||
|
return
|
||||||
|
self.s_spinner_start.emit()
|
||||||
|
try:
|
||||||
|
immediate = self._track_extras_provider(str(track_id), self._handle_track_extras_ready)
|
||||||
|
if immediate:
|
||||||
|
self._apply_track_extras(immediate)
|
||||||
|
self.s_spinner_stop.emit()
|
||||||
|
except Exception:
|
||||||
|
self.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def _handle_track_extras_ready(self, track_id: str, extras: dict | None) -> None:
|
||||||
|
"""Callback invoked by the provider when async extras are ready."""
|
||||||
|
try:
|
||||||
|
if extras:
|
||||||
|
current = self.current_media_hovered or self.current_media_selected
|
||||||
|
current_id = getattr(current, "id", None)
|
||||||
|
if current_id is not None and str(current_id) == str(track_id):
|
||||||
|
self._apply_track_extras(extras)
|
||||||
|
finally:
|
||||||
|
self.s_spinner_stop.emit()
|
||||||
|
|
||||||
|
def _apply_track_extras(self, extras: dict | None) -> None:
|
||||||
|
"""Apply the track extras to the UI.
|
||||||
|
|
||||||
|
This method updates the details tab with additional information such as
|
||||||
|
BPM if available.
|
||||||
|
|
||||||
|
Note: Only BPM is available from TIDAL API extras. Genres, label, producers
|
||||||
|
and composers are not reliably available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
extras (dict | None): A dictionary containing extra information
|
||||||
|
about the track, or None if no extras are available.
|
||||||
|
"""
|
||||||
|
if not isinstance(extras, dict):
|
||||||
|
return
|
||||||
|
with suppress(RuntimeError):
|
||||||
|
self._update_extras_ui(extras)
|
||||||
|
|
||||||
|
@QtCore.Slot(dict)
|
||||||
|
def _update_extras_ui(self, extras: dict):
|
||||||
|
"""Safely update UI with extras from the main thread."""
|
||||||
|
if not isinstance(extras, dict):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
bpm_value = extras.get("bpm")
|
||||||
|
display_value = "—" if bpm_value is None else str(bpm_value)
|
||||||
|
self.lbl_bpm.setText(display_value)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Widgets deleted, silently ignore
|
||||||
|
|
||||||
|
def update_on_hover(self, media: Track | Video | Album | Mix | Playlist | Artist | None) -> None:
|
||||||
|
"""Update display based on hovered media item.
|
||||||
|
|
||||||
|
This method is called when the user hovers over a track in the results list.
|
||||||
|
It displays information from pre-loaded metadata without triggering API calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media (Track | Video | Album | Mix | Playlist | Artist | None): The hovered media item.
|
||||||
|
"""
|
||||||
|
if not media:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_media_hovered = media
|
||||||
|
|
||||||
|
# Update details tab (from pre-loaded data)
|
||||||
|
self.s_update_details.emit(media)
|
||||||
|
|
||||||
|
# Cover is handled by MainWindow
|
||||||
|
self._request_track_extras_if_needed(media)
|
||||||
|
|
||||||
|
def update_on_selection(self, media: Track | Video | Album | Mix | Playlist | Artist | None) -> None:
|
||||||
|
"""Update display based on selected media item.
|
||||||
|
|
||||||
|
This method is called when the user clicks on a track in the results list.
|
||||||
|
It persists the selection so the UI can revert to it after hover leaves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media (Track | Video | Album | Mix | Playlist | Artist | None): The selected media item.
|
||||||
|
"""
|
||||||
|
if not media:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.current_media_selected = media
|
||||||
|
|
||||||
|
# Update both tabs
|
||||||
|
self.s_update_details.emit(media)
|
||||||
|
# Cover is handled by MainWindow
|
||||||
|
self._request_track_extras_if_needed(media)
|
||||||
|
|
||||||
|
def revert_to_selection(self) -> None:
|
||||||
|
"""Revert display to the currently selected media.
|
||||||
|
|
||||||
|
Called when hover leaves the results list to prevent leaving the UI
|
||||||
|
in an inconsistent state.
|
||||||
|
"""
|
||||||
|
if self.current_media_selected:
|
||||||
|
self.update_on_selection(self.current_media_selected)
|
||||||
|
else:
|
||||||
|
self._clear_display()
|
||||||
|
|
||||||
|
def _clear_display(self) -> None:
|
||||||
|
"""Clear all displayed information and reset to default state."""
|
||||||
|
self.current_media_hovered = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.lbl_title.setText("—")
|
||||||
|
self.lbl_version.setText("—")
|
||||||
|
self.lbl_artists.setText("—")
|
||||||
|
self.lbl_album.setText("—")
|
||||||
|
self.lbl_codec.setText("—")
|
||||||
|
self.lbl_bitrate.setText("—")
|
||||||
|
self.lbl_duration.setText("—")
|
||||||
|
self.lbl_release_date.setText("—")
|
||||||
|
self.lbl_popularity.setText("—")
|
||||||
|
self.lbl_bpm.setText("—")
|
||||||
|
self.lbl_isrc.setText("—")
|
||||||
|
self.lbl_track_number.setText("—")
|
||||||
|
|
||||||
|
# Reset cover to default
|
||||||
|
path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png")
|
||||||
|
self.cover_label.setPixmap(QtGui.QPixmap(path_image))
|
||||||
|
self.cover_url_current = ""
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Widgets deleted, silently ignore
|
||||||
|
|
||||||
|
def _on_update_details(self, media: Track | Video | Album | Mix | Playlist | Artist) -> None:
|
||||||
|
"""Update details tab with media metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media (Track | Video | Album | Mix | Playlist | Artist): The media item to display.
|
||||||
|
"""
|
||||||
|
# Handle different media types
|
||||||
|
if isinstance(media, Track):
|
||||||
|
self._populate_track_details(media)
|
||||||
|
elif isinstance(media, Video):
|
||||||
|
self._populate_video_details(media)
|
||||||
|
elif isinstance(media, Album):
|
||||||
|
self._populate_album_details(media)
|
||||||
|
elif isinstance(media, Playlist | Mix):
|
||||||
|
self._populate_playlist_details(media)
|
||||||
|
elif isinstance(media, Artist):
|
||||||
|
self._populate_artist_details(media)
|
||||||
|
|
||||||
|
def _populate_track_details(self, track: Track) -> None:
|
||||||
|
"""Populate details for a Track.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
track (Track): The track object.
|
||||||
|
"""
|
||||||
|
# Title and version
|
||||||
|
title = name_builder_title(track)
|
||||||
|
self.lbl_title.setText(self._create_link("track", title))
|
||||||
|
version = getattr(track, "version", None)
|
||||||
|
self.lbl_version.setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artist_parts = []
|
||||||
|
if hasattr(track, "artists") and track.artists:
|
||||||
|
for artist in track.artists:
|
||||||
|
name = TrackInfoFormatter._get_artist_name(artist)
|
||||||
|
roles = TrackInfoFormatter._format_artist_roles(artist)
|
||||||
|
link = self._create_link("artist", name)
|
||||||
|
if roles and roles[0] != "main":
|
||||||
|
artist_parts.append(f"{link} ({', '.join(roles)})")
|
||||||
|
else:
|
||||||
|
artist_parts.append(link)
|
||||||
|
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
|
||||||
|
|
||||||
|
# Album name (prefer album object's name)
|
||||||
|
album_name = None
|
||||||
|
if hasattr(track, "album") and track.album:
|
||||||
|
album_name = getattr(track.album, "name", None) or None
|
||||||
|
self.lbl_album.setText(self._create_link("album", album_name))
|
||||||
|
|
||||||
|
# Codec & Bitrate
|
||||||
|
codec = TrackInfoFormatter.format_codec(track)
|
||||||
|
self.lbl_codec.setText(safe_str(codec))
|
||||||
|
bitrate = TrackInfoFormatter.format_bitrate(track)
|
||||||
|
self.lbl_bitrate.setText(safe_str(bitrate))
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration = TrackInfoFormatter.format_duration(getattr(track, "duration", None))
|
||||||
|
self.lbl_duration.setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Release date (from album if exists)
|
||||||
|
release_date = None
|
||||||
|
if hasattr(track, "album") and track.album and hasattr(track.album, "release_date"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(track.album.release_date)
|
||||||
|
self.lbl_release_date.setText(safe_str(release_date))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(track, "popularity", None)
|
||||||
|
self.lbl_popularity.setText(safe_str(popularity))
|
||||||
|
|
||||||
|
# BPM: Show loading indicator, will be updated when extras are loaded
|
||||||
|
self.lbl_bpm.setText("⏳ Loading...")
|
||||||
|
|
||||||
|
# ISRC
|
||||||
|
isrc = getattr(track, "isrc", None)
|
||||||
|
self.lbl_isrc.setText(safe_str(isrc))
|
||||||
|
|
||||||
|
# Track Number
|
||||||
|
track_number = find_attr(track, "track_number", "tracknumber", "number", "position", "track") or None
|
||||||
|
if track_number is None and hasattr(track, "album") and track.album:
|
||||||
|
track_number = find_attr(track.album, "track_number", "tracknumber", "number") or None
|
||||||
|
self.lbl_track_number.setText(safe_str(track_number))
|
||||||
|
|
||||||
|
def _populate_video_details(self, video: Video) -> None:
|
||||||
|
"""Populate details for a Video.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video (Video): The video object.
|
||||||
|
"""
|
||||||
|
# Title and version
|
||||||
|
title = name_builder_title(video)
|
||||||
|
self.lbl_title.setText(self._create_link("video", title))
|
||||||
|
version = getattr(video, "version", None)
|
||||||
|
self.lbl_version.setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artists_str = extract_names_from_mixed(
|
||||||
|
getattr(video, "credits", None), match_types=("performer", "artist", "actor")
|
||||||
|
)
|
||||||
|
if not artists_str:
|
||||||
|
artist_parts = []
|
||||||
|
if hasattr(video, "artists") and video.artists:
|
||||||
|
for artist in video.artists:
|
||||||
|
name = TrackInfoFormatter._get_artist_name(artist)
|
||||||
|
artist_parts.append(self._create_link("artist", name))
|
||||||
|
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
|
||||||
|
else:
|
||||||
|
self.lbl_artists.setText(self._create_link("artist", artists_str))
|
||||||
|
|
||||||
|
# Album name
|
||||||
|
album_name = None
|
||||||
|
if hasattr(video, "album") and video.album:
|
||||||
|
album_name = getattr(video.album, "name", None) or None
|
||||||
|
self.lbl_album.setText(self._create_link("album", album_name))
|
||||||
|
|
||||||
|
# Codec & Bitrate
|
||||||
|
codec = TrackInfoFormatter.format_codec(video)
|
||||||
|
self.lbl_codec.setText(safe_str(codec))
|
||||||
|
bitrate = TrackInfoFormatter.format_bitrate(video)
|
||||||
|
self.lbl_bitrate.setText(safe_str(bitrate))
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration = TrackInfoFormatter.format_duration(getattr(video, "duration", None))
|
||||||
|
self.lbl_duration.setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Release date
|
||||||
|
release_date = None
|
||||||
|
if hasattr(video, "album") and video.album and hasattr(video.album, "release_date"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(video.album.release_date)
|
||||||
|
self.lbl_release_date.setText(safe_str(release_date))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(video, "popularity", None)
|
||||||
|
self.lbl_popularity.setText(safe_str(popularity))
|
||||||
|
|
||||||
|
# ISRC
|
||||||
|
isrc = getattr(video, "isrc", None)
|
||||||
|
if not isrc and hasattr(video, "track"):
|
||||||
|
isrc = getattr(video.track, "isrc", None)
|
||||||
|
self.lbl_isrc.setText(safe_str(isrc))
|
||||||
|
|
||||||
|
def _populate_album_details(self, album: Album) -> None:
|
||||||
|
"""Populate details for an Album.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
album (Album): The album object.
|
||||||
|
"""
|
||||||
|
# Title and version
|
||||||
|
title = name_builder_title(album)
|
||||||
|
self.lbl_title.setText(self._create_link("album", title))
|
||||||
|
version = getattr(album, "version", None)
|
||||||
|
self.lbl_version.setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artist_parts = []
|
||||||
|
if hasattr(album, "artists") and album.artists:
|
||||||
|
for artist in album.artists:
|
||||||
|
name = TrackInfoFormatter._get_artist_name(artist)
|
||||||
|
artist_parts.append(self._create_link("artist", name))
|
||||||
|
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
|
||||||
|
|
||||||
|
# Album name (same as title for an album)
|
||||||
|
self.lbl_album.setText(self._create_link("album", title))
|
||||||
|
|
||||||
|
# Codec & Bitrate
|
||||||
|
codec = TrackInfoFormatter.format_codec(album)
|
||||||
|
self.lbl_codec.setText(safe_str(codec))
|
||||||
|
bitrate = TrackInfoFormatter.format_bitrate(album)
|
||||||
|
self.lbl_bitrate.setText(safe_str(bitrate))
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration = TrackInfoFormatter.format_duration(getattr(album, "duration", None))
|
||||||
|
self.lbl_duration.setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Release date
|
||||||
|
release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None))
|
||||||
|
self.lbl_release_date.setText(safe_str(release_date))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(album, "popularity", None)
|
||||||
|
self.lbl_popularity.setText(safe_str(popularity))
|
||||||
|
|
||||||
|
self.lbl_isrc.setText("—")
|
||||||
|
|
||||||
|
def _extract_playlist_artists(self, playlist: Playlist | Mix) -> str:
|
||||||
|
"""Extract and format artists from playlist tracks."""
|
||||||
|
if not hasattr(playlist, "tracks") or not isinstance(playlist.tracks, list | Iterable):
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
all_artists = set()
|
||||||
|
for track in playlist.tracks:
|
||||||
|
if isinstance(track, Track | Video | Album) and hasattr(track, "artists"):
|
||||||
|
for artist in track.artists:
|
||||||
|
name = TrackInfoFormatter._get_artist_name(artist)
|
||||||
|
if name and name != "Unknown Artist":
|
||||||
|
all_artists.add(name)
|
||||||
|
|
||||||
|
if all_artists:
|
||||||
|
artist_links = [self._create_link("artist", name) for name in list(all_artists)[:5]]
|
||||||
|
return ", ".join(artist_links)
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
def _extract_playlist_release_date(self, playlist: Playlist | Mix) -> str:
|
||||||
|
"""Extract release date from playlist or its first track."""
|
||||||
|
release_date = TrackInfoFormatter.format_date(getattr(playlist, "release_date", None))
|
||||||
|
if release_date != "N/A":
|
||||||
|
return release_date
|
||||||
|
|
||||||
|
if hasattr(playlist, "tracks") and isinstance(playlist.tracks, list | Iterable):
|
||||||
|
for track in playlist.tracks:
|
||||||
|
if isinstance(track, Track | Video | Album) and hasattr(track, "album"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(getattr(track.album, "release_date", None))
|
||||||
|
if release_date and release_date != "N/A":
|
||||||
|
return release_date
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
def _populate_playlist_details(self, playlist: Playlist | Mix) -> None:
|
||||||
|
"""Populate details for a Playlist or Mix.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playlist (Playlist | Mix): The playlist or mix object.
|
||||||
|
"""
|
||||||
|
# Title and version
|
||||||
|
title = name_builder_title(playlist)
|
||||||
|
self.lbl_title.setText(self._create_link("playlist", title))
|
||||||
|
version = getattr(playlist, "version", None)
|
||||||
|
self.lbl_version.setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artists_html = self._extract_playlist_artists(playlist)
|
||||||
|
self.lbl_artists.setText(artists_html)
|
||||||
|
|
||||||
|
# Album name (playlist title)
|
||||||
|
album_name = getattr(playlist, "name", None)
|
||||||
|
self.lbl_album.setText(self._create_link("playlist", album_name))
|
||||||
|
|
||||||
|
# Codec & Bitrate
|
||||||
|
self.lbl_codec.setText("—")
|
||||||
|
self.lbl_bitrate.setText("—")
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration = TrackInfoFormatter.format_duration(getattr(playlist, "duration", None))
|
||||||
|
self.lbl_duration.setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Release date
|
||||||
|
release_date = self._extract_playlist_release_date(playlist)
|
||||||
|
self.lbl_release_date.setText(safe_str(release_date))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(playlist, "popularity", None)
|
||||||
|
self.lbl_popularity.setText(safe_str(popularity))
|
||||||
|
|
||||||
|
self.lbl_isrc.setText("—")
|
||||||
|
|
||||||
|
def _extract_artist_album_name(self, artist: Artist) -> str | None:
|
||||||
|
"""Extract first album name from artist's albums."""
|
||||||
|
if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for album in artist.albums:
|
||||||
|
if isinstance(album, Album) and hasattr(album, "name"):
|
||||||
|
return getattr(album, "name", None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _calculate_artist_total_duration(self, artist: Artist) -> int:
|
||||||
|
"""Calculate total duration of all artist's albums."""
|
||||||
|
if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_duration = 0
|
||||||
|
for album in artist.albums:
|
||||||
|
if isinstance(album, Album) and hasattr(album, "duration"):
|
||||||
|
album_duration = album.duration
|
||||||
|
if isinstance(album_duration, int | float):
|
||||||
|
total_duration += album_duration
|
||||||
|
return total_duration
|
||||||
|
|
||||||
|
def _extract_artist_release_date(self, artist: Artist) -> str | None:
|
||||||
|
"""Extract release date from artist's first album."""
|
||||||
|
if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for album in artist.albums:
|
||||||
|
if isinstance(album, Album) and hasattr(album, "release_date"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None))
|
||||||
|
if release_date and release_date != "N/A":
|
||||||
|
return release_date
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _populate_artist_details(self, artist: Artist) -> None:
|
||||||
|
"""Populate details for an Artist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist (Artist): The artist object.
|
||||||
|
"""
|
||||||
|
# Name
|
||||||
|
name = getattr(artist, "name", None)
|
||||||
|
self.lbl_title.setText(self._create_link("artist", name))
|
||||||
|
|
||||||
|
# Version
|
||||||
|
version = None
|
||||||
|
if hasattr(artist, "type"):
|
||||||
|
version = getattr(artist.type, "name", None)
|
||||||
|
elif hasattr(artist, "role"):
|
||||||
|
version = getattr(artist.role, "name", None)
|
||||||
|
self.lbl_version.setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
self.lbl_artists.setText("—")
|
||||||
|
|
||||||
|
# Album name
|
||||||
|
album_name = self._extract_artist_album_name(artist)
|
||||||
|
self.lbl_album.setText(self._create_link("album", album_name))
|
||||||
|
|
||||||
|
# Codec & Bitrate
|
||||||
|
self.lbl_codec.setText("—")
|
||||||
|
self.lbl_bitrate.setText("—")
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
total_duration = self._calculate_artist_total_duration(artist)
|
||||||
|
duration = TrackInfoFormatter.format_duration(total_duration)
|
||||||
|
self.lbl_duration.setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Release date
|
||||||
|
release_date = self._extract_artist_release_date(artist)
|
||||||
|
self.lbl_release_date.setText(safe_str(release_date))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(artist, "popularity", None)
|
||||||
|
self.lbl_popularity.setText(safe_str(popularity))
|
||||||
|
|
||||||
|
self.lbl_isrc.setText("—")
|
||||||
+205
-12
@@ -1,7 +1,7 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
## Form generated from reading UI file 'main.ui'
|
## Form generated from reading UI file 'main.ui'
|
||||||
##
|
##
|
||||||
## Created by: Qt User Interface Compiler version 6.9.0
|
## Created by: Qt User Interface Compiler version 6.10.0
|
||||||
##
|
##
|
||||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -11,6 +11,7 @@ from PySide6.QtGui import QAction, QFont, QIcon, QPixmap
|
|||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -21,6 +22,7 @@ from PySide6.QtWidgets import (
|
|||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QStatusBar,
|
QStatusBar,
|
||||||
|
QTabWidget,
|
||||||
QTreeView,
|
QTreeView,
|
||||||
QTreeWidget,
|
QTreeWidget,
|
||||||
QTreeWidgetItem,
|
QTreeWidgetItem,
|
||||||
@@ -146,9 +148,7 @@ class Ui_MainWindow:
|
|||||||
self.horizontalLayout.addLayout(self.lv_list_user)
|
self.horizontalLayout.addLayout(self.lv_list_user)
|
||||||
|
|
||||||
self.lv_search_result = QVBoxLayout()
|
self.lv_search_result = QVBoxLayout()
|
||||||
# ifndef Q_OS_MAC
|
self.lv_search_result.setSpacing(6)
|
||||||
self.lv_search_result.setSpacing(-1)
|
|
||||||
# endif
|
|
||||||
self.lv_search_result.setObjectName("lv_search_result")
|
self.lv_search_result.setObjectName("lv_search_result")
|
||||||
self.lh_search = QHBoxLayout()
|
self.lh_search = QHBoxLayout()
|
||||||
self.lh_search.setObjectName("lh_search")
|
self.lh_search.setObjectName("lh_search")
|
||||||
@@ -403,21 +403,182 @@ class Ui_MainWindow:
|
|||||||
self.lv_info_item.setObjectName("lv_info_item")
|
self.lv_info_item.setObjectName("lv_info_item")
|
||||||
self.horizontalLayout_2 = QHBoxLayout()
|
self.horizontalLayout_2 = QHBoxLayout()
|
||||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||||
self.l_pm_cover = QLabel(self.w_central)
|
self.tabWidget = QTabWidget(self.w_central)
|
||||||
self.l_pm_cover.setObjectName("l_pm_cover")
|
self.tabWidget.setObjectName("tabWidget")
|
||||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
self.tabWidget.setEnabled(True)
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||||
sizePolicy3.setHorizontalStretch(0)
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
sizePolicy3.setVerticalStretch(0)
|
sizePolicy3.setVerticalStretch(0)
|
||||||
sizePolicy3.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth())
|
sizePolicy3.setHeightForWidth(self.tabWidget.sizePolicy().hasHeightForWidth())
|
||||||
self.l_pm_cover.setSizePolicy(sizePolicy3)
|
self.tabWidget.setSizePolicy(sizePolicy3)
|
||||||
|
self.tabWidget.setMinimumSize(QSize(300, 300))
|
||||||
|
self.tabWidget.setMaximumSize(QSize(600, 16777215))
|
||||||
|
self.tabWidget.setUsesScrollButtons(True)
|
||||||
|
self.tabWidget.setDocumentMode(False)
|
||||||
|
self.tabWidget.setTabsClosable(False)
|
||||||
|
self.tabWidget.setMovable(True)
|
||||||
|
self.tabWidget.setTabBarAutoHide(False)
|
||||||
|
self.tab = QWidget()
|
||||||
|
self.tab.setObjectName("tab")
|
||||||
|
self.details_vlayout = QVBoxLayout(self.tab)
|
||||||
|
self.details_vlayout.setSpacing(4)
|
||||||
|
self.details_vlayout.setObjectName("details_vlayout")
|
||||||
|
self.formLayout_details = QFormLayout()
|
||||||
|
self.formLayout_details.setObjectName("formLayout_details")
|
||||||
|
self.formLayout_details.setLabelAlignment(
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
self.formLayout_details.setHorizontalSpacing(2)
|
||||||
|
self.formLayout_details.setVerticalSpacing(2)
|
||||||
|
self.l_label_title = QLabel(self.tab)
|
||||||
|
self.l_label_title.setObjectName("l_label_title")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(0, QFormLayout.ItemRole.LabelRole, self.l_label_title)
|
||||||
|
|
||||||
|
self.lbl_title = QLabel(self.tab)
|
||||||
|
self.lbl_title.setObjectName("lbl_title")
|
||||||
|
self.lbl_title.setWordWrap(True)
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(0, QFormLayout.ItemRole.FieldRole, self.lbl_title)
|
||||||
|
|
||||||
|
self.l_label_version = QLabel(self.tab)
|
||||||
|
self.l_label_version.setObjectName("l_label_version")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(1, QFormLayout.ItemRole.LabelRole, self.l_label_version)
|
||||||
|
|
||||||
|
self.lbl_version = QLabel(self.tab)
|
||||||
|
self.lbl_version.setObjectName("lbl_version")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(1, QFormLayout.ItemRole.FieldRole, self.lbl_version)
|
||||||
|
|
||||||
|
self.l_label_artists = QLabel(self.tab)
|
||||||
|
self.l_label_artists.setObjectName("l_label_artists")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(2, QFormLayout.ItemRole.LabelRole, self.l_label_artists)
|
||||||
|
|
||||||
|
self.lbl_artists = QLabel(self.tab)
|
||||||
|
self.lbl_artists.setObjectName("lbl_artists")
|
||||||
|
self.lbl_artists.setWordWrap(True)
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(2, QFormLayout.ItemRole.FieldRole, self.lbl_artists)
|
||||||
|
|
||||||
|
self.l_label_track_number = QLabel(self.tab)
|
||||||
|
self.l_label_track_number.setObjectName("l_label_track_number")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(3, QFormLayout.ItemRole.LabelRole, self.l_label_track_number)
|
||||||
|
|
||||||
|
self.lbl_track_number = QLabel(self.tab)
|
||||||
|
self.lbl_track_number.setObjectName("lbl_track_number")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(3, QFormLayout.ItemRole.FieldRole, self.lbl_track_number)
|
||||||
|
|
||||||
|
self.l_label_album = QLabel(self.tab)
|
||||||
|
self.l_label_album.setObjectName("l_label_album")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(4, QFormLayout.ItemRole.LabelRole, self.l_label_album)
|
||||||
|
|
||||||
|
self.lbl_album = QLabel(self.tab)
|
||||||
|
self.lbl_album.setObjectName("lbl_album")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(4, QFormLayout.ItemRole.FieldRole, self.lbl_album)
|
||||||
|
|
||||||
|
self.l_label_duration = QLabel(self.tab)
|
||||||
|
self.l_label_duration.setObjectName("l_label_duration")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(5, QFormLayout.ItemRole.LabelRole, self.l_label_duration)
|
||||||
|
|
||||||
|
self.lbl_duration = QLabel(self.tab)
|
||||||
|
self.lbl_duration.setObjectName("lbl_duration")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(5, QFormLayout.ItemRole.FieldRole, self.lbl_duration)
|
||||||
|
|
||||||
|
self.l_label_codec = QLabel(self.tab)
|
||||||
|
self.l_label_codec.setObjectName("l_label_codec")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(6, QFormLayout.ItemRole.LabelRole, self.l_label_codec)
|
||||||
|
|
||||||
|
self.lbl_codec = QLabel(self.tab)
|
||||||
|
self.lbl_codec.setObjectName("lbl_codec")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(6, QFormLayout.ItemRole.FieldRole, self.lbl_codec)
|
||||||
|
|
||||||
|
self.l_label_bitrate = QLabel(self.tab)
|
||||||
|
self.l_label_bitrate.setObjectName("l_label_bitrate")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(7, QFormLayout.ItemRole.LabelRole, self.l_label_bitrate)
|
||||||
|
|
||||||
|
self.lbl_bitrate = QLabel(self.tab)
|
||||||
|
self.lbl_bitrate.setObjectName("lbl_bitrate")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(7, QFormLayout.ItemRole.FieldRole, self.lbl_bitrate)
|
||||||
|
|
||||||
|
self.l_label_release_date = QLabel(self.tab)
|
||||||
|
self.l_label_release_date.setObjectName("l_label_release_date")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(8, QFormLayout.ItemRole.LabelRole, self.l_label_release_date)
|
||||||
|
|
||||||
|
self.lbl_release_date = QLabel(self.tab)
|
||||||
|
self.lbl_release_date.setObjectName("lbl_release_date")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(8, QFormLayout.ItemRole.FieldRole, self.lbl_release_date)
|
||||||
|
|
||||||
|
self.l_label_popularity = QLabel(self.tab)
|
||||||
|
self.l_label_popularity.setObjectName("l_label_popularity")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(9, QFormLayout.ItemRole.LabelRole, self.l_label_popularity)
|
||||||
|
|
||||||
|
self.lbl_popularity = QLabel(self.tab)
|
||||||
|
self.lbl_popularity.setObjectName("lbl_popularity")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(9, QFormLayout.ItemRole.FieldRole, self.lbl_popularity)
|
||||||
|
|
||||||
|
self.l_label_bpm = QLabel(self.tab)
|
||||||
|
self.l_label_bpm.setObjectName("l_label_bpm")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(10, QFormLayout.ItemRole.LabelRole, self.l_label_bpm)
|
||||||
|
|
||||||
|
self.lbl_bpm = QLabel(self.tab)
|
||||||
|
self.lbl_bpm.setObjectName("lbl_bpm")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(10, QFormLayout.ItemRole.FieldRole, self.lbl_bpm)
|
||||||
|
|
||||||
|
self.l_label_isrc = QLabel(self.tab)
|
||||||
|
self.l_label_isrc.setObjectName("l_label_isrc")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(11, QFormLayout.ItemRole.LabelRole, self.l_label_isrc)
|
||||||
|
|
||||||
|
self.lbl_isrc = QLabel(self.tab)
|
||||||
|
self.lbl_isrc.setObjectName("lbl_isrc")
|
||||||
|
|
||||||
|
self.formLayout_details.setWidget(11, QFormLayout.ItemRole.FieldRole, self.lbl_isrc)
|
||||||
|
|
||||||
|
self.details_vlayout.addLayout(self.formLayout_details)
|
||||||
|
|
||||||
|
self.tabWidget.addTab(self.tab, "")
|
||||||
|
self.tab_2 = QWidget()
|
||||||
|
self.tab_2.setObjectName("tab_2")
|
||||||
|
self.cover_vlayout = QVBoxLayout(self.tab_2)
|
||||||
|
self.cover_vlayout.setSpacing(2)
|
||||||
|
self.cover_vlayout.setObjectName("cover_vlayout")
|
||||||
|
self.l_pm_cover = QLabel(self.tab_2)
|
||||||
|
self.l_pm_cover.setObjectName("l_pm_cover")
|
||||||
|
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
sizePolicy4.setHorizontalStretch(0)
|
||||||
|
sizePolicy4.setVerticalStretch(0)
|
||||||
|
sizePolicy4.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth())
|
||||||
|
self.l_pm_cover.setSizePolicy(sizePolicy4)
|
||||||
|
self.l_pm_cover.setMinimumSize(QSize(280, 280))
|
||||||
self.l_pm_cover.setMaximumSize(QSize(280, 280))
|
self.l_pm_cover.setMaximumSize(QSize(280, 280))
|
||||||
self.l_pm_cover.setBaseSize(QSize(0, 0))
|
|
||||||
self.l_pm_cover.setFrameShape(QFrame.Shape.NoFrame)
|
self.l_pm_cover.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
self.l_pm_cover.setPixmap(QPixmap("default_album_image.png"))
|
self.l_pm_cover.setPixmap(QPixmap("default_album_image.png"))
|
||||||
self.l_pm_cover.setScaledContents(True)
|
self.l_pm_cover.setScaledContents(True)
|
||||||
self.l_pm_cover.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
|
self.l_pm_cover.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
self.horizontalLayout_2.addWidget(self.l_pm_cover)
|
self.cover_vlayout.addWidget(self.l_pm_cover)
|
||||||
|
|
||||||
|
self.tabWidget.addTab(self.tab_2, "")
|
||||||
|
|
||||||
|
self.horizontalLayout_2.addWidget(self.tabWidget)
|
||||||
|
|
||||||
self.lv_info_item.addLayout(self.horizontalLayout_2)
|
self.lv_info_item.addLayout(self.horizontalLayout_2)
|
||||||
|
|
||||||
@@ -497,7 +658,7 @@ class Ui_MainWindow:
|
|||||||
MainWindow.setCentralWidget(self.w_central)
|
MainWindow.setCentralWidget(self.w_central)
|
||||||
self.menubar = QMenuBar(MainWindow)
|
self.menubar = QMenuBar(MainWindow)
|
||||||
self.menubar.setObjectName("menubar")
|
self.menubar.setObjectName("menubar")
|
||||||
self.menubar.setGeometry(QRect(0, 0, 1200, 24))
|
self.menubar.setGeometry(QRect(0, 0, 1200, 33))
|
||||||
# if QT_CONFIG(tooltip)
|
# if QT_CONFIG(tooltip)
|
||||||
self.menubar.setToolTip("")
|
self.menubar.setToolTip("")
|
||||||
# endif // QT_CONFIG(tooltip)
|
# endif // QT_CONFIG(tooltip)
|
||||||
@@ -563,6 +724,8 @@ class Ui_MainWindow:
|
|||||||
|
|
||||||
self.retranslateUi(MainWindow)
|
self.retranslateUi(MainWindow)
|
||||||
|
|
||||||
|
self.tabWidget.setCurrentIndex(1)
|
||||||
|
|
||||||
QMetaObject.connectSlotsByName(MainWindow)
|
QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
|
||||||
# setupUi
|
# setupUi
|
||||||
@@ -589,7 +752,37 @@ class Ui_MainWindow:
|
|||||||
self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None))
|
self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None))
|
||||||
self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None))
|
self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None))
|
||||||
self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
|
self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
|
||||||
|
self.l_label_title.setText(QCoreApplication.translate("MainWindow", "Title", None))
|
||||||
|
self.lbl_title.setText("")
|
||||||
|
self.l_label_version.setText(QCoreApplication.translate("MainWindow", "Version", None))
|
||||||
|
self.lbl_version.setText("")
|
||||||
|
self.l_label_artists.setText(QCoreApplication.translate("MainWindow", "Artists", None))
|
||||||
|
self.lbl_artists.setText("")
|
||||||
|
self.l_label_track_number.setText(QCoreApplication.translate("MainWindow", "Track #", None))
|
||||||
|
self.lbl_track_number.setText("")
|
||||||
|
self.l_label_album.setText(QCoreApplication.translate("MainWindow", "Album", None))
|
||||||
|
self.lbl_album.setText("")
|
||||||
|
self.l_label_duration.setText(QCoreApplication.translate("MainWindow", "Duration", None))
|
||||||
|
self.lbl_duration.setText("")
|
||||||
|
self.l_label_codec.setText(QCoreApplication.translate("MainWindow", "Codec", None))
|
||||||
|
self.lbl_codec.setText("")
|
||||||
|
self.l_label_bitrate.setText(QCoreApplication.translate("MainWindow", "Bitrate", None))
|
||||||
|
self.lbl_bitrate.setText("")
|
||||||
|
self.l_label_release_date.setText(QCoreApplication.translate("MainWindow", "Release Date", None))
|
||||||
|
self.lbl_release_date.setText("")
|
||||||
|
self.l_label_popularity.setText(QCoreApplication.translate("MainWindow", "Popularity", None))
|
||||||
|
self.lbl_popularity.setText("")
|
||||||
|
self.l_label_bpm.setText(QCoreApplication.translate("MainWindow", "BPM", None))
|
||||||
|
self.lbl_bpm.setText("")
|
||||||
|
self.l_label_isrc.setText(QCoreApplication.translate("MainWindow", "ISRC", None))
|
||||||
|
self.lbl_isrc.setText("")
|
||||||
|
self.tabWidget.setTabText(
|
||||||
|
self.tabWidget.indexOf(self.tab), QCoreApplication.translate("MainWindow", "Details", None)
|
||||||
|
)
|
||||||
self.l_pm_cover.setText("")
|
self.l_pm_cover.setText("")
|
||||||
|
self.tabWidget.setTabText(
|
||||||
|
self.tabWidget.indexOf(self.tab_2), QCoreApplication.translate("MainWindow", "Cover Art", None)
|
||||||
|
)
|
||||||
self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None))
|
self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None))
|
||||||
___qtreewidgetitem4 = self.tr_queue_download.headerItem()
|
___qtreewidgetitem4 = self.tr_queue_download.headerItem()
|
||||||
___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Quality Video", None))
|
___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Quality Video", None))
|
||||||
|
|||||||
+256
-6
@@ -197,7 +197,7 @@
|
|||||||
<item>
|
<item>
|
||||||
<layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0">
|
<layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0">
|
||||||
<property name="spacing">
|
<property name="spacing">
|
||||||
<number>-1</number>
|
<number>6</number>
|
||||||
</property>
|
</property>
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="lh_search">
|
<layout class="QHBoxLayout" name="lh_search">
|
||||||
@@ -522,6 +522,252 @@
|
|||||||
<layout class="QVBoxLayout" name="lv_info_item">
|
<layout class="QVBoxLayout" name="lv_info_item">
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="QTabWidget" name="tabWidget">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>300</width>
|
||||||
|
<height>300</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>600</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="currentIndex">
|
||||||
|
<number>1</number>
|
||||||
|
</property>
|
||||||
|
<property name="usesScrollButtons">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="documentMode">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="tabsClosable">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="movable">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="tabBarAutoHide">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="tab">
|
||||||
|
<attribute name="title">
|
||||||
|
<string>Details</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="details_vlayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>4</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QFormLayout" name="formLayout_details">
|
||||||
|
<property name="labelAlignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
<property name="horizontalSpacing">
|
||||||
|
<number>2</number>
|
||||||
|
</property>
|
||||||
|
<property name="verticalSpacing">
|
||||||
|
<number>2</number>
|
||||||
|
</property>
|
||||||
|
<item row="0" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_title">
|
||||||
|
<property name="text">
|
||||||
|
<string>Title</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_title">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_version">
|
||||||
|
<property name="text">
|
||||||
|
<string>Version</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_version">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_artists">
|
||||||
|
<property name="text">
|
||||||
|
<string>Artists</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="2" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_artists">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_track_number">
|
||||||
|
<property name="text">
|
||||||
|
<string>Track #</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_track_number">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="4" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_album">
|
||||||
|
<property name="text">
|
||||||
|
<string>Album</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="4" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_album">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="5" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_duration">
|
||||||
|
<property name="text">
|
||||||
|
<string>Duration</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="5" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_duration">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="6" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_codec">
|
||||||
|
<property name="text">
|
||||||
|
<string>Codec</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="6" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_codec">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="7" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_bitrate">
|
||||||
|
<property name="text">
|
||||||
|
<string>Bitrate</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="7" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_bitrate">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="8" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_release_date">
|
||||||
|
<property name="text">
|
||||||
|
<string>Release Date</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="8" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_release_date">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="9" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_popularity">
|
||||||
|
<property name="text">
|
||||||
|
<string>Popularity</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="9" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_popularity">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="10" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_bpm">
|
||||||
|
<property name="text">
|
||||||
|
<string>BPM</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="10" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_bpm">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="11" column="0">
|
||||||
|
<widget class="QLabel" name="l_label_isrc">
|
||||||
|
<property name="text">
|
||||||
|
<string>ISRC</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="11" column="1">
|
||||||
|
<widget class="QLabel" name="lbl_isrc">
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QWidget" name="tab_2">
|
||||||
|
<attribute name="title">
|
||||||
|
<string>Cover Art</string>
|
||||||
|
</attribute>
|
||||||
|
<layout class="QVBoxLayout" name="cover_vlayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>2</number>
|
||||||
|
</property>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="l_pm_cover">
|
<widget class="QLabel" name="l_pm_cover">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
@@ -530,16 +776,16 @@
|
|||||||
<verstretch>0</verstretch>
|
<verstretch>0</verstretch>
|
||||||
</sizepolicy>
|
</sizepolicy>
|
||||||
</property>
|
</property>
|
||||||
<property name="maximumSize">
|
<property name="minimumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>280</width>
|
<width>280</width>
|
||||||
<height>280</height>
|
<height>280</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
<property name="baseSize">
|
<property name="maximumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>0</width>
|
<width>280</width>
|
||||||
<height>0</height>
|
<height>280</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
<property name="frameShape">
|
<property name="frameShape">
|
||||||
@@ -560,6 +806,10 @@
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
@@ -706,7 +956,7 @@
|
|||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>1200</width>
|
<width>1200</width>
|
||||||
<height>24</height>
|
<height>33</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="toolTip">
|
<property name="toolTip">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Helpers for populating media details in InfoTabWidget - Reduces code duplication."""
|
||||||
|
|
||||||
|
from tidal_dl_ng.helper.metadata_utils import find_attr, safe_str
|
||||||
|
from tidal_dl_ng.helper.tidal import name_builder_title
|
||||||
|
from tidal_dl_ng.ui.info_tab_widget import TrackInfoFormatter
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDetailsHelper:
|
||||||
|
"""Helper class to populate media details, reducing duplication in InfoTabWidget."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_basic_fields(labels: dict, media, media_type: str = "track") -> None:
|
||||||
|
"""Populate fields common to all media types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
labels: Dictionary mapping field names to QLabel widgets
|
||||||
|
media: The media object (Track, Video, Album, etc.)
|
||||||
|
media_type: Type of media ("track", "video", "album")
|
||||||
|
"""
|
||||||
|
# Title and version
|
||||||
|
title = name_builder_title(media) if media_type != "album" else getattr(media, "name", None)
|
||||||
|
version = getattr(media, "version", None)
|
||||||
|
labels["title"].setText(safe_str(title))
|
||||||
|
labels["version"].setText(safe_str(version))
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artists = TrackInfoFormatter.format_artists(media)
|
||||||
|
labels["artists"].setText(safe_str(artists))
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
duration = TrackInfoFormatter.format_duration(getattr(media, "duration", None))
|
||||||
|
labels["duration"].setText(safe_str(duration))
|
||||||
|
|
||||||
|
# Popularity
|
||||||
|
popularity = getattr(media, "popularity", None)
|
||||||
|
labels["popularity"].setText(safe_str(popularity))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_album_fields(labels: dict, media) -> None:
|
||||||
|
"""Populate album-related fields."""
|
||||||
|
album_name = None
|
||||||
|
release_date = None
|
||||||
|
|
||||||
|
if hasattr(media, "album") and media.album:
|
||||||
|
album_name = getattr(media.album, "name", None)
|
||||||
|
if hasattr(media.album, "release_date"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(media.album.release_date)
|
||||||
|
elif hasattr(media, "release_date"):
|
||||||
|
release_date = TrackInfoFormatter.format_date(media.release_date)
|
||||||
|
|
||||||
|
labels["album"].setText(safe_str(album_name))
|
||||||
|
labels["release_date"].setText(safe_str(release_date))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_technical_fields(labels: dict, media) -> None:
|
||||||
|
"""Populate technical fields (codec, bitrate, ISRC, track number)."""
|
||||||
|
# Codec & Bitrate
|
||||||
|
codec = TrackInfoFormatter.format_codec(media)
|
||||||
|
labels["codec"].setText(safe_str(codec))
|
||||||
|
bitrate = TrackInfoFormatter.format_bitrate(media)
|
||||||
|
labels["bitrate"].setText(safe_str(bitrate))
|
||||||
|
|
||||||
|
# ISRC
|
||||||
|
isrc = getattr(media, "isrc", None)
|
||||||
|
labels["isrc"].setText(safe_str(isrc))
|
||||||
|
|
||||||
|
# Track Number
|
||||||
|
track_number = find_attr(media, "track_number", "tracknumber", "number", "position")
|
||||||
|
if not track_number and hasattr(media, "album") and media.album:
|
||||||
|
track_number = find_attr(media.album, "track_number", "tracknumber")
|
||||||
|
labels["track_number"].setText(safe_str(track_number))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_metadata_fields(labels: dict, media) -> None:
|
||||||
|
"""Populate metadata fields (BPM, Label, Producers, Composers)."""
|
||||||
|
# BPM
|
||||||
|
bpm = find_attr(media, "bpm", "tempo")
|
||||||
|
if not bpm and hasattr(media, "album") and media.album:
|
||||||
|
bpm = find_attr(media.album, "bpm", "tempo")
|
||||||
|
labels["bpm"].setText(safe_str(bpm))
|
||||||
|
|
||||||
|
# Label
|
||||||
|
label = None
|
||||||
|
if hasattr(media, "album") and media.album:
|
||||||
|
label = find_attr(media.album, "label", "label_name", "recordLabel")
|
||||||
|
if not label:
|
||||||
|
label = find_attr(media, "label", "label_name", "recordLabel")
|
||||||
|
labels["label"].setText(safe_str(label))
|
||||||
|
|
||||||
|
# Producers/Composers
|
||||||
|
producers = find_attr(media, "producers", "producer")
|
||||||
|
labels["producers"].setText(safe_str(producers) if producers else "—")
|
||||||
|
|
||||||
|
composers = find_attr(media, "composers", "composer")
|
||||||
|
labels["composers"].setText(safe_str(composers) if composers else "—")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_genres(labels: dict, media) -> None:
|
||||||
|
"""Extract and populate genre information with filtering."""
|
||||||
|
genres = find_attr(media, "genres", "genre")
|
||||||
|
if not genres and hasattr(media, "album") and media.album:
|
||||||
|
genres = find_attr(media.album, "genres", "genre")
|
||||||
|
|
||||||
|
if not genres:
|
||||||
|
labels["genres"].setText("—")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Normalize to list and filter technical tags
|
||||||
|
genre_list = genres if isinstance(genres, list | tuple) else [genres]
|
||||||
|
excluded = ("atmos", "dolby", "hq", "lossless", "mqa", "flac", "wav", "pcm", "audio", "bit", "track")
|
||||||
|
filtered = [
|
||||||
|
str(g) for g in genre_list if g and isinstance(g, str) and not any(kw in g.lower() for kw in excluded)
|
||||||
|
]
|
||||||
|
|
||||||
|
labels["genres"].setText(safe_str(", ".join(filtered)) if filtered else "—")
|
||||||
Reference in New Issue
Block a user