From 9b84566eb4ccf4d088e459cba71d105ec0bda615 Mon Sep 17 00:00:00 2001 From: Dongho Kim Date: Tue, 2 Dec 2025 14:07:35 +0100 Subject: [PATCH] update --- .gitignore | 49 + Dockerfile | 27 + README.md | 66 + app/main.py | 23 + app/routers/auth.py | 28 + app/routers/download.py | 40 + app/routers/search.py | 14 + app/routers/system.py | 17 + app/services/download_manager.py | 309 +++ app/services/tidal_wrapper.py | 161 ++ app/static/style.css | 123 ++ app/templates/index.html | 200 ++ app/templates/login.html | 61 + docker-compose.yml | 48 + requirements.txt | 17 + run.py | 4 + tidal-dl-ng-source | 1 + tidal_dl_ng/__init__.py | 145 ++ tidal_dl_ng/api.py | 121 ++ tidal_dl_ng/cli.py | 536 +++++ tidal_dl_ng/config.py | 293 +++ tidal_dl_ng/constants.py | 97 + tidal_dl_ng/dialog.py | 360 ++++ tidal_dl_ng/download.py | 1787 +++++++++++++++++ tidal_dl_ng/gui.py | 2521 ++++++++++++++++++++++++ tidal_dl_ng/helper/__init__.py | 0 tidal_dl_ng/helper/decorator.py | 22 + tidal_dl_ng/helper/decryption.py | 63 + tidal_dl_ng/helper/exceptions.py | 14 + tidal_dl_ng/helper/gui.py | 225 +++ tidal_dl_ng/helper/path.py | 691 +++++++ tidal_dl_ng/helper/tidal.py | 274 +++ tidal_dl_ng/helper/wrapper.py | 26 + tidal_dl_ng/logger.py | 65 + tidal_dl_ng/metadata.py | 206 ++ tidal_dl_ng/model/__init__.py | 0 tidal_dl_ng/model/cfg.py | 145 ++ tidal_dl_ng/model/downloader.py | 24 + tidal_dl_ng/model/gui_data.py | 50 + tidal_dl_ng/model/meta.py | 14 + tidal_dl_ng/ui/__init__.py | 0 tidal_dl_ng/ui/default_album_image.png | Bin 0 -> 4960 bytes tidal_dl_ng/ui/dialog_login.py | 119 ++ tidal_dl_ng/ui/dialog_login.ui | 195 ++ tidal_dl_ng/ui/dialog_settings.py | 660 +++++++ tidal_dl_ng/ui/dialog_settings.ui | 846 ++++++++ tidal_dl_ng/ui/dialog_version.py | 161 ++ tidal_dl_ng/ui/dialog_version.ui | 227 +++ tidal_dl_ng/ui/dummy_register.py | 33 + tidal_dl_ng/ui/dummy_wiggly.py | 68 + tidal_dl_ng/ui/icon.icns | Bin 0 -> 159805 bytes tidal_dl_ng/ui/icon.ico | Bin 0 -> 196338 bytes tidal_dl_ng/ui/icon16.png | Bin 0 -> 382 bytes tidal_dl_ng/ui/icon256.png | Bin 0 -> 1894 bytes tidal_dl_ng/ui/icon32.png | Bin 0 -> 670 bytes tidal_dl_ng/ui/icon48.png | Bin 0 -> 1211 bytes tidal_dl_ng/ui/icon512.png | Bin 0 -> 25668 bytes tidal_dl_ng/ui/icon64.png | Bin 0 -> 536 bytes tidal_dl_ng/ui/main.py | 607 ++++++ tidal_dl_ng/ui/main.ui | 823 ++++++++ tidal_dl_ng/ui/spinner.py | 221 +++ tidal_dl_ng/worker.py | 34 + 62 files changed, 12861 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/main.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/download.py create mode 100644 app/routers/search.py create mode 100644 app/routers/system.py create mode 100644 app/services/download_manager.py create mode 100644 app/services/tidal_wrapper.py create mode 100644 app/static/style.css create mode 100644 app/templates/index.html create mode 100644 app/templates/login.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 run.py create mode 160000 tidal-dl-ng-source create mode 100644 tidal_dl_ng/__init__.py create mode 100644 tidal_dl_ng/api.py create mode 100644 tidal_dl_ng/cli.py create mode 100644 tidal_dl_ng/config.py create mode 100644 tidal_dl_ng/constants.py create mode 100644 tidal_dl_ng/dialog.py create mode 100644 tidal_dl_ng/download.py create mode 100644 tidal_dl_ng/gui.py create mode 100644 tidal_dl_ng/helper/__init__.py create mode 100644 tidal_dl_ng/helper/decorator.py create mode 100644 tidal_dl_ng/helper/decryption.py create mode 100644 tidal_dl_ng/helper/exceptions.py create mode 100644 tidal_dl_ng/helper/gui.py create mode 100644 tidal_dl_ng/helper/path.py create mode 100644 tidal_dl_ng/helper/tidal.py create mode 100644 tidal_dl_ng/helper/wrapper.py create mode 100644 tidal_dl_ng/logger.py create mode 100644 tidal_dl_ng/metadata.py create mode 100644 tidal_dl_ng/model/__init__.py create mode 100644 tidal_dl_ng/model/cfg.py create mode 100644 tidal_dl_ng/model/downloader.py create mode 100644 tidal_dl_ng/model/gui_data.py create mode 100644 tidal_dl_ng/model/meta.py create mode 100644 tidal_dl_ng/ui/__init__.py create mode 100644 tidal_dl_ng/ui/default_album_image.png create mode 100644 tidal_dl_ng/ui/dialog_login.py create mode 100644 tidal_dl_ng/ui/dialog_login.ui create mode 100644 tidal_dl_ng/ui/dialog_settings.py create mode 100644 tidal_dl_ng/ui/dialog_settings.ui create mode 100644 tidal_dl_ng/ui/dialog_version.py create mode 100644 tidal_dl_ng/ui/dialog_version.ui create mode 100644 tidal_dl_ng/ui/dummy_register.py create mode 100644 tidal_dl_ng/ui/dummy_wiggly.py create mode 100644 tidal_dl_ng/ui/icon.icns create mode 100644 tidal_dl_ng/ui/icon.ico create mode 100644 tidal_dl_ng/ui/icon16.png create mode 100644 tidal_dl_ng/ui/icon256.png create mode 100644 tidal_dl_ng/ui/icon32.png create mode 100644 tidal_dl_ng/ui/icon48.png create mode 100644 tidal_dl_ng/ui/icon512.png create mode 100644 tidal_dl_ng/ui/icon64.png create mode 100644 tidal_dl_ng/ui/main.py create mode 100644 tidal_dl_ng/ui/main.ui create mode 100644 tidal_dl_ng/ui/spinner.py create mode 100644 tidal_dl_ng/worker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c2abe8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Docker +.docker/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# Mac +.DS_Store + +# Project specific +config/ +downloads/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d873bc4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +# Install system dependencies +# ffmpeg is required for media processing +RUN apt-get update && apt-get install -y \ + ffmpeg \ + git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy requirements first to leverage cache +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create directory for downloads and config +RUN mkdir -p /app/downloads /app/config + +# Expose port +EXPOSE 8000 + +# Run the application +# We use uvicorn directly here instead of run.py to allow better control over host/port +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bf5e08 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Tidal DL Web + +A modern web interface for downloading music from Tidal, built on top of `tidal-dl-ng`. + +## ⚠️ Disclaimer + +**This project is for educational purposes only.** I am not responsible for any problems, damages, or legal issues that may arise from using this software. Use at your own risk and ensure you comply with Tidal's Terms of Service and all applicable laws in your jurisdiction. + +## Features + +- **Web Interface**: Clean and responsive dashboard to manage your downloads. +- **Search**: Search for Tracks, Albums, Artists, and Playlists. +- **Grouped Results**: Search results are organized by Album or Artist for easy navigation. +- **Download Queue**: Monitor your downloads with detailed progress (Track X of Y, Percentage). +- **Controls**: Pause, Resume, and Cancel downloads directly from the queue. +- **Dockerized**: Easy to deploy and run using Docker. + +## Installation & Usage + +### Prerequisites + +- Docker and Docker Compose installed on your machine. +- A valid Tidal account (subscription required for high quality). + +### Running with Docker + +1. **Clone the repository**: + ```bash + git clone + cd tidal-dl-web + ``` + +2. **Start the application**: + ```bash + docker-compose up -d + ``` + +3. **Access the Web UI**: + Open your browser and navigate to `http://localhost:8002`. + +4. **Login**: + Follow the on-screen instructions to authenticate with your Tidal account using the device login flow. + +## Configuration + +You can configure the download path in `docker-compose.yml`: + +```yaml +environment: + - DOWNLOAD_PATH=/app/downloads +volumes: + - ./downloads:/app/downloads +``` + +- `DOWNLOAD_PATH`: The internal path where files are saved in the container. +- `./downloads:/app/downloads`: Maps the local `downloads` folder to the container's download directory. + +## Development + +To run in development mode with live reloading: + +The `docker-compose.yml` is already configured to mount the `app` directory and enable reload. Just make changes to the code, and the server will auto-restart. + +## Credits + +Based on [tidal-dl-ng](https://github.com/exislow/tidal-dl-ng). diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..eb10403 --- /dev/null +++ b/app/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse +from app.routers import auth, search, download, system +from app.services.tidal_wrapper import TidalWrapper + +app = FastAPI(title="Tidal DL Web") + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +app.include_router(auth.router) +app.include_router(search.router) +app.include_router(download.router) +app.include_router(system.router) + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + wrapper = TidalWrapper() + if not wrapper.is_authenticated(): + return templates.TemplateResponse("login.html", {"request": request}) + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..f0e9b22 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, Request, Depends +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.templating import Jinja2Templates +from app.services.tidal_wrapper import TidalWrapper + +router = APIRouter(prefix="/auth", tags=["auth"]) +templates = Jinja2Templates(directory="app/templates") + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + +@router.post("/start") +async def start_login(): + wrapper = TidalWrapper() + result = wrapper.start_device_login() + return JSONResponse(result) + +@router.get("/status") +async def check_status(): + wrapper = TidalWrapper() + return JSONResponse(wrapper.get_auth_status()) + +@router.post("/logout") +async def logout(): + # wrapper = TidalWrapper() + # wrapper.logout() + return JSONResponse({"status": "logged_out"}) diff --git a/app/routers/download.py b/app/routers/download.py new file mode 100644 index 0000000..b67c507 --- /dev/null +++ b/app/routers/download.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +from app.services.download_manager import DownloadManager + +router = APIRouter(prefix="/download", tags=["download"]) +download_manager = DownloadManager() + +@router.post("/") +async def add_download(request: Request): + data = await request.json() + item_type = data.get("type") + item_id = data.get("id") + + if not item_type or not item_id: + raise HTTPException(status_code=400, detail="Missing type or id") + + task = download_manager.add_to_queue(item_type, item_id) + return JSONResponse(task) + +@router.get("/queue") +async def get_queue(): + return download_manager.get_queue() + +@router.post("/cancel/{task_id}") +async def cancel_download(task_id: str): + if download_manager.cancel_task(task_id): + return {"status": "success", "message": "Task cancelled"} + raise HTTPException(status_code=404, detail="Task not found") + +@router.post("/pause/{task_id}") +async def pause_download(task_id: str): + if download_manager.pause_task(task_id): + return {"status": "success", "message": "Task paused"} + raise HTTPException(status_code=404, detail="Task not found") + +@router.post("/resume/{task_id}") +async def resume_download(task_id: str): + if download_manager.resume_task(task_id): + return {"status": "success", "message": "Task resumed"} + raise HTTPException(status_code=404, detail="Task not found") diff --git a/app/routers/search.py b/app/routers/search.py new file mode 100644 index 0000000..922870d --- /dev/null +++ b/app/routers/search.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +from app.services.tidal_wrapper import TidalWrapper + +router = APIRouter(prefix="/search", tags=["search"]) + +@router.get("/") +async def search(query: str, type: str = "track"): + wrapper = TidalWrapper() + try: + results = wrapper.search(query, type) + return JSONResponse(results) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/app/routers/system.py b/app/routers/system.py new file mode 100644 index 0000000..c81d185 --- /dev/null +++ b/app/routers/system.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter +import requests + +router = APIRouter( + prefix="/system", + tags=["system"], + responses={404: {"description": "Not found"}}, +) + +@router.get("/ip") +async def get_public_ip(): + try: + response = requests.get("https://api.ipify.org?format=json", timeout=5) + response.raise_for_status() + return response.json() + except Exception as e: + return {"ip": "Error fetching IP", "error": str(e)} diff --git a/app/services/download_manager.py b/app/services/download_manager.py new file mode 100644 index 0000000..af59011 --- /dev/null +++ b/app/services/download_manager.py @@ -0,0 +1,309 @@ +import threading +import queue +import logging +import sys +from typing import Dict, List +from tidal_dl_ng.download import Download +from tidal_dl_ng.config import Settings, Tidal + +class MockTask: + def __init__(self, total): + self.finished = False + self.percentage = 0 + self.total = total + self.completed = 0 + +class MockProgressRich: + def __init__(self): + self.tasks = [] + def add_task(self, *args, **kwargs): + total = kwargs.get("total", 100) + self.tasks.append(MockTask(total)) + return len(self.tasks) - 1 + def update(self, task_id, *args, **kwargs): + task = self.tasks[task_id] + if "completed" in kwargs: + task.completed = kwargs["completed"] + if "advance" in kwargs: + task.completed += kwargs["advance"] + + if task.total and task.completed >= task.total: + task.finished = True + + def advance(self, task_id, advance=1): + self.update(task_id, advance=advance) + + def remove_task(self, *args, **kwargs): + pass + def start(self): + pass + def stop(self): + pass + +from tidal_dl_ng.model.gui_data import ProgressBars +from .tidal_wrapper import TidalWrapper + +logger = logging.getLogger(__name__) + +class DownloadManager: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(DownloadManager, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self.queue = queue.Queue() + self.active_downloads: Dict[str, Dict] = {} + self.history: List[Dict] = [] + self.worker_thread = threading.Thread(target=self._worker, daemon=True) + self.worker_thread.start() + self.tidal_wrapper = TidalWrapper() + self._initialized = True + + def add_to_queue(self, item_type: str, item_id: str): + task_id = f"{item_type}_{item_id}" + task = { + "id": task_id, + "type": item_type, + "item_id": item_id, + "status": "queued", + "progress": 0, + "name": "Fetching metadata..." + } + self.active_downloads[task_id] = task + self.queue.put(task) + return task + + def get_queue(self): + return list(self.active_downloads.values()) + self.history + + def _worker(self): + while True: + task = self.queue.get() + if task is None: + break + + task_id = task["id"] + self.active_downloads[task_id]["status"] = "downloading" + + try: + self._process_download(task) + self.active_downloads[task_id]["status"] = "completed" + self.active_downloads[task_id]["progress"] = 100 + except Exception as e: + logger.error(f"Download failed: {e}") + self.active_downloads[task_id]["status"] = "failed" + self.active_downloads[task_id]["error"] = str(e) + finally: + # Move to history + self.history.append(self.active_downloads.pop(task_id)) + self.queue.task_done() + + def cancel_task(self, task_id: str): + if task_id in self.active_downloads: + self.active_downloads[task_id]["control"] = "cancel" + return True + return False + + def pause_task(self, task_id: str): + if task_id in self.active_downloads: + self.active_downloads[task_id]["control"] = "pause" + return True + return False + + def resume_task(self, task_id: str): + if task_id in self.active_downloads: + self.active_downloads[task_id]["control"] = "resume" + return True + return False + + def _check_control(self, task): + # Check for control flags + while task.get("control") == "pause": + time.sleep(1) + # If cancelled while paused + if task.get("control") == "cancel": + break + + if task.get("control") == "cancel": + task["status"] = "cancelled" + raise Exception("Download cancelled by user") + + def _process_download(self, task): + # Mock Signal class to capture emit calls + class MockSignal: + def __init__(self, callback): + self.callback = callback + def emit(self, value): + self.callback(value) + + # Mock ProgressBars class + class MockProgressBars: + def __init__(self, task_updater): + self.item = MockSignal(lambda p: task_updater("progress", p)) + self.item_name = MockSignal(lambda n: task_updater("name", n)) + self.list_item = MockSignal(lambda p: task_updater("list_progress", p)) + self.list_name = MockSignal(lambda n: task_updater("list_name", n)) + + def update_task(key, value): + if key == "progress": + # For single track, this is the main progress + if task["type"] == "track": + self.active_downloads[task["id"]]["progress"] = value + elif key == "name": + # Update status with current track name + self.active_downloads[task["id"]]["current_item"] = value + elif key == "list_progress": + # For albums/playlists, this is the main progress + if task["type"] != "track": + self.active_downloads[task["id"]]["progress"] = value + + mock_progress = MockProgressBars(update_task) + + settings = Settings() + tidal = Tidal(settings) + + # Attempt to load token from storage + tidal.login_token() + + # Ensure we are logged in + if not tidal.session.check_login(): + raise Exception("Not logged in") + + # Use environment variable for download path, default to /app/downloads + import os + settings.data.download_base_path = os.getenv("DOWNLOAD_PATH", "/app/downloads") + settings.data.path_binary_ffmpeg = "ffmpeg" + + # Configure logger + logger_tidal = logging.getLogger("tidal_dl_ng") + logger_tidal.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + if not logger_tidal.handlers: + logger_tidal.addHandler(handler) + + mock_progress_rich = MockProgressRich() + event_abort = threading.Event() + event_run = threading.Event() + event_run.set() # Allow running + + downloader = Download( + tidal_obj=tidal, + path_base=settings.data.download_base_path, + fn_logger=logger_tidal, + skip_existing=False, + progress_gui=mock_progress, + progress=mock_progress_rich, + event_abort=event_abort, + event_run=event_run + ) + + # Fetch media object + media = None + if task["type"] == "track": + media = tidal.session.track(task["item_id"]) + elif task["type"] == "album": + media = tidal.session.album(task["item_id"]) + elif task["type"] == "video": + media = tidal.session.video(task["item_id"]) + elif task["type"] == "playlist": + media = tidal.session.playlist(task["item_id"]) + elif task["type"] == "artist": + media = tidal.session.artist(task["item_id"]) + + if not media: + raise Exception("Media not found") + + task["name"] = f"{media.name}" + if hasattr(media, "artist"): + task["name"] += f" - {media.artist.name}" + + self._check_control(task) + + if task["type"] == "track": + result, path = downloader.item( + file_template=settings.data.format_track, + media=media, + quality_audio=settings.data.quality_audio + ) + if not result: + raise Exception("Download failed (downloader returned False)") + logger.info(f"Download successful: {path}") + elif task["type"] == "album": + tracks = media.tracks() + total = len(tracks) + task["total_items"] = total + + for i, track in enumerate(tracks): + self._check_control(task) + task["current_index"] = i + 1 + task["progress"] = int((i / total) * 100) + task["current_item"] = track.name + + downloader.item( + file_template=settings.data.format_track, + media=track, + quality_audio=settings.data.quality_audio, + is_parent_album=True, + list_position=i+1, + list_total=total + ) + elif task["type"] == "playlist": + tracks = media.tracks() + total = len(tracks) + task["total_items"] = total + + for i, track in enumerate(tracks): + self._check_control(task) + task["current_index"] = i + 1 + task["progress"] = int((i / total) * 100) + task["current_item"] = track.name + + downloader.item( + file_template=settings.data.format_track, + media=track, + quality_audio=settings.data.quality_audio, + is_parent_album=False, # Playlist tracks are treated as individual tracks usually, or we can use playlist format + list_position=i+1, + list_total=total + ) + elif task["type"] == "artist": + # For artist, download all albums + albums = media.get_albums() + total_albums = len(albums) + + # We can't easily know total tracks upfront without fetching all albums first. + # Let's track progress by Album for the top level, and maybe tracks within? + # Or just flatten everything. Flattening is better for "Track X of Y". + + all_tracks = [] + for album in albums: + all_tracks.extend(album.tracks()) + + total = len(all_tracks) + task["total_items"] = total + + for i, track in enumerate(all_tracks): + self._check_control(task) + task["current_index"] = i + 1 + task["progress"] = int((i / total) * 100) + task["current_item"] = track.name + + downloader.item( + file_template=settings.data.format_track, + media=track, + quality_audio=settings.data.quality_audio, + is_parent_album=True, # Treat as album tracks to keep folder structure + list_position=track.track_num, # Use original track num + list_total=track.album.num_tracks + ) + + diff --git a/app/services/tidal_wrapper.py b/app/services/tidal_wrapper.py new file mode 100644 index 0000000..96f0bab --- /dev/null +++ b/app/services/tidal_wrapper.py @@ -0,0 +1,161 @@ +import logging +import threading +import time +from typing import Optional, Callable, Dict, Any +from tidal_dl_ng.config import Tidal, Settings +from tidalapi import Session, Track, Album, Artist, Playlist + +logger = logging.getLogger(__name__) + +class TidalWrapper: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(TidalWrapper, cls).__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self.settings = Settings() + self.tidal = Tidal(self.settings) + self.session = self.tidal.session + self._initialized = True + self.auth_future = None + self.auth_status = {"status": "idle", "message": "", "link": "", "code": ""} + + def is_authenticated(self) -> bool: + return self.session.check_login() + + def start_device_login(self) -> Dict[str, str]: + """ + Starts the device login process. + Returns the verification info (link and code) immediately if possible, + or starts a thread to handle the blocking login_oauth_simple if needed. + + Since tidalapi's login_oauth_simple is blocking and takes a print callback, + we might need to run it in a thread and capture the output. + However, a better approach is to use the underlying tidalapi methods if available. + + For now, let's try to use the session's internal methods if we can figure them out, + or wrap the blocking call. + """ + if self.is_authenticated(): + return {"status": "success", "message": "Already logged in"} + + # Reset status + self.auth_status = {"status": "pending", "message": "Initializing login...", "link": "", "code": ""} + + # Run login in a separate thread to avoid blocking + thread = threading.Thread(target=self._login_thread) + thread.daemon = True + thread.start() + + return self.auth_status + + def _login_thread(self): + def print_callback(msg: str): + logger.info(f"Tidal Login Callback: {msg}") + # Parse the message to extract link and code if possible + # Typical msg: "Visit https://link.tidal.com/AAAAA and enter code AAAAA" + self.auth_status["message"] = msg + if "http" in msg: + parts = msg.split() + for part in parts: + if part.startswith("http"): + self.auth_status["link"] = part + # This is a bit hacky, but tidalapi 0.7+ might behave differently. + # If we can't parse it easily, the user just sees the message. + + try: + # This will block until user logs in or times out + self.tidal.login(print_callback) + if self.is_authenticated(): + self.auth_status["status"] = "success" + self.auth_status["message"] = "Login successful!" + else: + self.auth_status["status"] = "failed" + self.auth_status["message"] = "Login failed." + except Exception as e: + self.auth_status["status"] = "error" + self.auth_status["message"] = str(e) + + def get_auth_status(self): + return self.auth_status + + def search(self, query: str, type: str = "track", limit: int = 10): + if not self.is_authenticated(): + raise Exception("Not authenticated") + + # Map type string to tidalapi models if needed, or just pass string + # tidalapi.Session.search(query, models=None, limit=10, offset=0) + # models can be [Track, Album, Artist, Playlist] + + models = [] + if type == "track": + models = [Track] + elif type == "album": + models = [Album] + elif type == "artist": + models = [Artist] + elif type == "playlist": + models = [Playlist] + + results = self.session.search(query, models=models, limit=limit) + + # Parse results into a JSON-friendly format + output = [] + if type == "track": + for track in results["tracks"]: + output.append({ + "id": track.id, + "title": track.name, + "artist": track.artist.name, + "album": track.album.name, + "duration": track.duration, + "cover": self._get_image_url(track.album.cover), + "type": "track" + }) + elif type == "album": + for album in results["albums"]: + output.append({ + "id": album.id, + "title": album.name, + "artist": album.artist.name, + "tracks": album.num_tracks, + "release_date": str(album.release_date), + "cover": self._get_image_url(album.cover), + "type": "album" + }) + elif type == "artist": + for artist in results["artists"]: + output.append({ + "id": artist.id, + "title": artist.name, + "type": "artist", + "cover": self._get_image_url(artist.picture) + }) + elif type == "playlist": + for playlist in results["playlists"]: + output.append({ + "id": playlist.id, + "title": playlist.name, + "type": "playlist", + "cover": self._get_image_url(playlist.cover) + }) + + return output + + def _get_image_url(self, uuid: str | None, width: int = 320, height: int = 320) -> str | None: + if not uuid: + return None + return f"https://resources.tidal.com/images/{uuid.replace('-', '/')}/{width}x{height}.jpg" + + + def get_track(self, track_id: str): + return self.session.track(track_id) + + def get_album(self, album_id: str): + return self.session.album(album_id) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..e7001ea --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,123 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #121212; + color: #e0e0e0; + margin: 0; + padding: 0; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +h1, +h2 { + color: #00ffff; +} + +.card { + background-color: #1e1e1e; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +input[type="text"] { + width: 100%; + padding: 10px; + margin-bottom: 10px; + background-color: #2d2d2d; + border: 1px solid #333; + color: #fff; + border-radius: 4px; +} + +button { + background-color: #00ffff; + color: #000; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: bold; +} + +button:hover { + background-color: #00cccc; +} + +.result-item { + display: flex; + align-items: center; + padding: 10px; + border-bottom: 1px solid #333; +} + +.result-item img { + width: 50px; + height: 50px; + margin-right: 15px; + border-radius: 4px; +} + +.result-info { + flex-grow: 1; +} + +.queue-item { + padding: 10px; + border-bottom: 1px solid #333; + display: flex; + justify-content: space-between; +} + +.status-completed { + color: #00ff00; +} + +.status-downloading { + color: #ffff00; +} + +.status-failed { + color: #ff0000; +} + +/* Collapsible Styles */ +details { + margin-bottom: 10px; + background-color: #252525; + border-radius: 4px; + overflow: hidden; +} + +summary { + padding: 10px; + cursor: pointer; + background-color: #333; + font-weight: bold; + color: #00ffff; + list-style: none; + /* Hide default triangle */ +} + +summary::-webkit-details-marker { + display: none; +} + +summary::after { + content: '+'; + float: right; + font-weight: bold; +} + +details[open] summary::after { + content: '-'; +} + +.group-list { + padding: 0 10px; +} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..272e9bc --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,200 @@ + + + + + + + Tidal DL Web + + + + +
+

Tidal DL Web

+ +
+

Search

+ + + +
+ + + +
+

Download Queue

+
+
+
+ +
+
+ System IP: Loading... +
+
+ + + + + \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..01780d6 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,61 @@ + + + + + + Tidal DL - Login + + + +
+

Tidal DL Web

+
+

Login Required

+

Please login to your Tidal account to continue.

+
+ +
+ +
+
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3be8526 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + gluetun: + image: qmcgaw/gluetun + container_name: gluetun + cap_add: + - NET_ADMIN + env_file: + - .env + # ports: + # - "8002:8080" # Expose tidal-dl-web port via gluetun + restart: always + networks: + - proxy + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.tidal.entrypoints=http + - traefik.http.routers.tidal.rule=Host(`tidal.ekstrah.com`) + - traefik.http.middlewares.tidal-redirect.redirectscheme.permanent=true + - traefik.http.middlewares.tidal-redirect.redirectscheme.scheme=https + - traefik.http.routers.tidal.middlewares=tidal-redirect + - traefik.http.routers.tidal-secure.entrypoints=https + - traefik.http.routers.tidal-secure.rule=Host(`tidal.ekstrah.com`) + - traefik.http.routers.tidal-secure.tls=true + - traefik.http.routers.tidal-secure.tls.certresolver=cloudflare + - traefik.http.services.tidal-secure-service.loadbalancer.server.port=8080 + web: + build: . + container_name: tidal-dl-web + network_mode: "service:gluetun" + volumes: + - ./downloads:/app/downloads + - ./config:/app/config + # Mount source for development + - ./app:/app/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload + environment: + - PYTHONUNBUFFERED=1 + - DOWNLOAD_PATH=/app/downloads + - XDG_CONFIG_HOME=/app/config + restart: unless-stopped + depends_on: + gluetun: + condition: service_healthy + +networks: + proxy: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c6be036 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +fastapi +uvicorn +jinja2 +python-multipart +requests +mutagen +dataclasses-json +pathvalidate +m3u8 +coloredlogs +rich +toml +typer +tidalapi +python-ffmpeg +pycryptodome +ansi2html diff --git a/run.py b/run.py new file mode 100644 index 0000000..46643e1 --- /dev/null +++ b/run.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8002, reload=True) diff --git a/tidal-dl-ng-source b/tidal-dl-ng-source new file mode 160000 index 0000000..9d5a659 --- /dev/null +++ b/tidal-dl-ng-source @@ -0,0 +1 @@ +Subproject commit 9d5a65945b4a4de8240424bf40d1fe463ced7a1c diff --git a/tidal_dl_ng/__init__.py b/tidal_dl_ng/__init__.py new file mode 100644 index 0000000..990c705 --- /dev/null +++ b/tidal_dl_ng/__init__.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python +import importlib.metadata +from pathlib import Path +from urllib.parse import urlparse + +import requests +import toml + +from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC +from tidal_dl_ng.model.meta import ProjectInformation, ReleaseLatest + + +def metadata_project() -> ProjectInformation: + result: ProjectInformation + file_path: Path = Path(__file__) + tmp_result: dict = {} + + paths: list[Path] = [ + file_path.parent, + file_path.parent.parent, + file_path.parent.parent.parent, + ] + + for pyproject_toml_dir in paths: + pyproject_toml_file: Path = pyproject_toml_dir / "pyproject.toml" + + if pyproject_toml_file.is_file(): + tmp_result = toml.load(pyproject_toml_file) + + break + + if tmp_result: + result = ProjectInformation( + version=tmp_result["project"]["version"], repository_url=tmp_result["project"]["urls"]["repository"] + ) + else: + try: + meta_info = importlib.metadata.metadata(name_package()) + repo_url = meta_info["Home-page"] + + if not repo_url: + urls = meta_info.get_all("Project-URL") + # attempt to parse, else use hardcoded fallback + repo_url = next( + (url.split(", ")[1] for url in urls if url.startswith("Repository")), + "https://github.com/exislow/tidal-dl-ng", + ) + + result = ProjectInformation(version=meta_info["Version"], repository_url=repo_url) + except Exception: + result = ProjectInformation(version="0.0.0", repository_url="https://anerroroccur.ed/sorry/for/that") + + return result + + +def version_app() -> str: + metadata: ProjectInformation = metadata_project() + version: str = metadata.version + + return version + + +def repository_url() -> str: + metadata: ProjectInformation = metadata_project() + url_repo: str = metadata.repository_url + + return url_repo + + +def repository_path() -> str: + url_repo: str = repository_url() + url_path: str = urlparse(url_repo).path + + return url_path + + +def latest_version_information() -> ReleaseLatest: + release_info: ReleaseLatest + repo_path: str = repository_path() + url: str = f"https://api.github.com/repos{repo_path}/releases/latest" + + try: + response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC) + response.raise_for_status() + + release_info_json: dict = response.json() + + release_info = ReleaseLatest( + version=release_info_json["tag_name"], + url=release_info_json["html_url"], + release_info=release_info_json["body"], + ) + except (requests.RequestException, KeyError, ValueError): + release_info = ReleaseLatest( + version="v0.0.0", + url=url, + release_info=f"Something went wrong calling {url}. Check your internet connection.", + ) + + return release_info + + +def name_package() -> str: + package_name: str = __package__ or __name__ + + return package_name + + +def is_dev_env() -> bool: + package_name: str = name_package() + result: bool = False + + # Check if package is running from source code == dev mode + # If package is not running in Nuitka environment, try to import it from pip libraries. + # If this also fails, it is dev mode. + if "__compiled__" not in globals(): + try: + importlib.metadata.version(package_name) + except Exception: + # If package is not installed + result = True + + return result + + +def name_app() -> str: + app_name: str = name_package() + is_dev: bool = is_dev_env() + + if is_dev: + app_name += "-dev" + + return app_name + + +__name_display__ = name_app() +__version__ = version_app() + + +def update_available() -> tuple[bool, ReleaseLatest]: + latest_info: ReleaseLatest = latest_version_information() + version_current: str = f"v{__version__}" + + result = version_current not in [latest_info.version, "v0.0.0"] + return result, latest_info diff --git a/tidal_dl_ng/api.py b/tidal_dl_ng/api.py new file mode 100644 index 0000000..1f942db --- /dev/null +++ b/tidal_dl_ng/api.py @@ -0,0 +1,121 @@ +import json + +import requests + +# See also +# https://github.com/yaronzz/Tidal-Media-Downloader/commit/1d5b8cd8f65fd1def45d6406778248249d6dfbdf +# https://github.com/yaronzz/Tidal-Media-Downloader/pull/840 +# https://github.com/nathom/streamrip/tree/main/streamrip +# https://github.com/arnesongit/plugin.audio.tidal2/blob/e9429d601d0c303d775d05a19a66415b57479d87/resources/lib/tidal2/tidalapi/__init__.py#L86 + +# TODO: Implement this into `Download`: Session should randomize the usage. +__KEYS_JSON__ = """ +{ + "version": "1.0.1", + "keys": [ + // Invalid + { + "platform": "Fire TV", + "formats": "Normal/High/HiFi(No Master)", + "clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm", + "clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=", + "valid": "False", + "from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)" + }, + // Only max MQA. + { + "platform": "Fire TV", + "formats": "Master-Only(Else Error)", + "clientId": "7m7Ap0JC9j1cOM3n", + "clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=", + "valid": "True", + "from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)" + }, + // Invalid + { + "platform": "Android TV", + "formats": "Normal/High/HiFi(No Master)", + "clientId": "Pzd0ExNVHkyZLiYN", + "clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=", + "valid": "False", + "from": "" + }, + // Invalid + { + "platform": "TV", + "formats": "Normal/High/HiFi/Master", + "clientId": "8SEZWa4J1NVC5U5Y", + "clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=", + "valid": "False", + "from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)" + }, + // Invalid + { + "platform": "Android Auto", + "formats": "Normal/High/HiFi/Master", + "clientId": "zU4XHVVkc2tDPo4t", + "clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=", + "valid": "True", + "from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)" + } + ] +} +""" +__API_KEYS__ = json.loads(__KEYS_JSON__) +__ERROR_KEY__ = ( + { + "platform": "None", + "formats": "", + "clientId": "", + "clientSecret": "", + "valid": "False", + }, +) + +from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC + + +def getNum(): + return len(__API_KEYS__["keys"]) + + +def getItem(index: int): + if index < 0 or index >= len(__API_KEYS__["keys"]): + return __ERROR_KEY__ + return __API_KEYS__["keys"][index] + + +def isItemValid(index: int): + item = getItem(index) + return item["valid"] == "True" + + +def getItems(): + return __API_KEYS__["keys"] + + +def getLimitIndexs(): + array = [] + for i in range(len(__API_KEYS__["keys"])): + array.append(str(i)) + return array + + +def getVersion(): + return __API_KEYS__["version"] + + +# Load from gist +try: + respond = requests.get( + "https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6", timeout=REQUESTS_TIMEOUT_SEC + ) + respond.raise_for_status() + + if respond.status_code == 200: + content = respond.json()["files"]["tidal-api-key.json"]["content"] + __API_KEYS__ = json.loads(content) +except requests.RequestException as e: + # Failed to load API keys from gist, will use fallback keys + print(f"Failed to load API keys from gist: {e}") + pass diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py new file mode 100644 index 0000000..b1e607c --- /dev/null +++ b/tidal_dl_ng/cli.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python +import signal +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Annotated +from urllib.parse import urlparse + +import typer +from rich.console import Console, Group +from rich.live import Live +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, +) +from rich.table import Table + +from tidal_dl_ng import __version__ +from tidal_dl_ng.config import HandlingApp, Settings, Tidal +from tidal_dl_ng.constants import CTX_TIDAL, MediaType +from tidal_dl_ng.download import Download +from tidal_dl_ng.helper.path import get_format_template, path_file_settings +from tidal_dl_ng.helper.tidal import ( + all_artist_album_ids, + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, + url_ending_clean, +) +from tidal_dl_ng.helper.wrapper import LoggerWrapped +from tidal_dl_ng.model.cfg import HelpSettings + +app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}, add_completion=False) +app_dl_fav = typer.Typer( + context_settings={"help_option_names": ["-h", "--help"]}, + add_completion=True, + help="Download from a favorites collection.", +) + +app.add_typer(app_dl_fav, name="dl_fav") + + +def version_callback(value: bool): + """Callback to print version and exit if version flag is set. + + Args: + value (bool): If True, prints version and exits. + """ + if value: + print(f"{__version__}") + + raise typer.Exit() + + +@app.callback() +def callback_app( + ctx: typer.Context, + version: Annotated[bool | None, typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None, +): + """App callback to initialize context and handle version option. + + Args: + ctx (typer.Context): Typer context object. + version (bool | None, optional): Version flag. Defaults to None. + """ + ctx.obj = {"tidal": None} + + +def _handle_track_or_video( + dl: Download, ctx: typer.Context, item: str, media: object, file_template: str, idx: int, urls_pos_last: int +) -> None: + """Handle downloading a track or video item. + + Args: + dl (Download): The Download instance. + ctx (typer.Context): Typer context object. + item (str): The URL or identifier of the item. + media: The media object to download. + file_template (str): The file template for saving the media. + idx (int): The index of the item in the list. + urls_pos_last (int): The last index in the URLs list. + """ + settings = ctx.obj[CTX_TIDAL].settings + download_delay: bool = bool(settings.data.download_delay and idx < urls_pos_last) + + dl.item( + media=media, + file_template=file_template, + download_delay=download_delay, + quality_audio=settings.data.quality_audio, + quality_video=settings.data.quality_video, + ) + + +def _handle_album_playlist_mix_artist( + ctx: typer.Context, + dl: Download, + handling_app: HandlingApp, + media_type: MediaType, + media: object, + item_id: str, + file_template: str, +) -> bool: + """Handle downloading albums, playlists, mixes, or artist collections. + + Args: + ctx (typer.Context): Typer context object. + dl (Download): The Download instance. + handling_app (HandlingApp): The HandlingApp instance. + media_type (MediaType): The type of media (album, playlist, mix, or artist). + media: The media object to download. + item_id (str): The ID of the media item. + file_template (str): The file template for saving the media. + + Returns: + bool: False if aborted, True otherwise. + """ + item_ids: list[str] = [] + settings = ctx.obj[CTX_TIDAL].settings + + if media_type == MediaType.ARTIST: + media_type = MediaType.ALBUM + item_ids += all_artist_album_ids(media) + else: + item_ids.append(item_id) + + for _item_id in item_ids: + if handling_app.event_abort.is_set(): + return False + + dl.items( + media_id=_item_id, + media_type=media_type, + file_template=file_template, + video_download=settings.data.video_download, + download_delay=settings.data.download_delay, + quality_audio=settings.data.quality_audio, + quality_video=settings.data.quality_video, + ) + + return True + + +def _process_url( + dl: Download, + ctx: typer.Context, + handling_app: HandlingApp, + url: str, + idx: int, + urls_pos_last: int, +) -> bool: + """Process a single URL or ID for download. + + Args: + dl (Download): The Download instance. + ctx (typer.Context): Typer context object. + handling_app (HandlingApp): The HandlingApp instance. + url (str): The URL or identifier to process. + idx (int): The index of the url in the list. + urls_pos_last (int): The last index in the URLs list. + + Returns: + bool: False if aborted, True otherwise. + """ + settings = ctx.obj[CTX_TIDAL].settings + + if handling_app.event_abort.is_set(): + return False + + if "http" not in url: + print(f"It seems like you have supplied an invalid URL: {url}") + return True + + url_clean: str = url_ending_clean(url) + + media_type = get_tidal_media_type(url_clean) + if not isinstance(media_type, MediaType): + print(f"Could not determine media type for: {url_clean}") + return True + + url_clean_id = get_tidal_media_id(url_clean) + if not isinstance(url_clean_id, str): + print(f"Could not determine media id for: {url_clean}") + return True + + file_template = get_format_template(media_type, settings) + if not isinstance(file_template, str): + print(f"Could not determine file template for: {url_clean}") + return True + + try: + media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, url_clean_id) + except Exception: + print(f"Media not found (ID: {url_clean_id}). Maybe it is not available anymore.") + return True + + if media_type in [MediaType.TRACK, MediaType.VIDEO]: + _handle_track_or_video(dl, ctx, url_clean, media, file_template, idx, urls_pos_last) + elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: + return _handle_album_playlist_mix_artist(ctx, dl, handling_app, media_type, media, url_clean_id, file_template) + return True + + +def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bool: + """Invokes download function and tracks progress. + + Args: + ctx (typer.Context): The typer context object. + urls (list[str]): The list of URLs to download. + try_login (bool, optional): If true, attempts to login to TIDAL. Defaults to True. + + Returns: + bool: True if ran successfully. + """ + if try_login: + ctx.invoke(login, ctx) + + settings: Settings = ctx.obj[CTX_TIDAL].settings + handling_app: HandlingApp = HandlingApp() + + progress: Progress = Progress( + TextColumn("[progress.description]{task.description}"), + SpinnerColumn(), + BarColumn(), + TaskProgressColumn(), + refresh_per_second=20, + auto_refresh=True, + expand=True, + transient=False, + ) + + progress_overall = Progress( + TextColumn("[progress.description]{task.description}"), + SpinnerColumn(), + BarColumn(), + TaskProgressColumn(), + refresh_per_second=20, + auto_refresh=True, + expand=True, + transient=False, + ) + + fn_logger = LoggerWrapped(progress.print) + + dl = Download( + tidal_obj=ctx.obj[CTX_TIDAL], + skip_existing=settings.data.skip_existing, + path_base=settings.data.download_base_path, + fn_logger=fn_logger, + progress=progress, + progress_overall=progress_overall, + event_abort=handling_app.event_abort, + event_run=handling_app.event_run, + ) + + progress_table = Table.grid() + progress_table.add_row(progress) + progress_table.add_row(progress_overall) + progress_group = Group(progress_table) + + urls_pos_last = len(urls) - 1 + + with Live(progress_group, refresh_per_second=20, vertical_overflow="visible"): + try: + for idx, item in enumerate(urls): + if _process_url(dl, ctx, handling_app, item, idx, urls_pos_last) is False: + return False + finally: + progress.refresh() + progress.stop() + + return True + + +@app.command(name="cfg") +def settings_management( + names: Annotated[list[str] | None, typer.Argument()] = None, + editor: Annotated[ + bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.") + ] = False, +) -> None: + """Print or set an option, or open the settings file in an editor. + + Args: + names (list[str] | None, optional): None (list all options), one (list the value only for this option) or two arguments (set the value for the option). Defaults to None. + editor (bool, optional): If set, your default system editor will be opened. Defaults to False. + """ + if editor: + config_path: Path = Path(path_file_settings()) + + if not config_path.is_file(): + config_path.write_text('{"version": "1.0.0"}') + + config_file_str = str(config_path) + + typer.launch(config_file_str) + else: + settings = Settings() + d_settings = settings.data.to_dict() + + if names: + if names[0] not in d_settings: + print(f'Option "{names[0]}" is not valid!') + elif len(names) == 1: + print(f'{names[0]}: "{d_settings[names[0]]}"') + elif len(names) > 1: + settings.set_option(names[0], names[1]) + settings.save() + else: + help_settings: dict = HelpSettings().to_dict() + table = Table(title=f"Config: {path_file_settings()}") + table.add_column("Key", style="cyan", no_wrap=True) + table.add_column("Value", style="magenta") + table.add_column("Description", style="green") + + for key, value in sorted(d_settings.items()): + table.add_row(key, str(value), help_settings[key]) + + console = Console() + console.print(table) + + +@app.command(name="login") +def login(ctx: typer.Context) -> bool: + """Login to TIDAL and update context object. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: True if login was successful, False otherwise. + """ + print("Let us check if you are already logged in... ", end="") + + settings = Settings() + tidal = Tidal(settings) + result = tidal.login(fn_print=print) + ctx.obj[CTX_TIDAL] = tidal + + return result + + +@app.command(name="logout") +def logout() -> bool: + """Logout from TIDAL. + + Returns: + bool: True if logout was successful, False otherwise. + """ + settings = Settings() + tidal = Tidal(settings) + result = tidal.logout() + + if result: + print("You have been successfully logged out.") + + return result + + +@app.command(name="dl") +def download( + ctx: typer.Context, + urls: Annotated[list[str] | None, typer.Argument()] = None, + file_urls: Annotated[ + Path | None, + typer.Option( + "--list", + "-l", + exists=True, + file_okay=True, + dir_okay=False, + writable=False, + readable=True, + resolve_path=True, + help="File with URLs to download. One URL per line.", + ), + ] = None, +) -> bool: + """Download media from provided URLs or a file containing URLs. + + Args: + ctx (typer.Context): Typer context object. + urls (list[str] | None, optional): List of URLs to download. Defaults to None. + file_urls (Path | None, optional): Path to file containing URLs. Defaults to None. + + Returns: + bool: True if download was successful, False otherwise. + """ + if not urls: + # Read the text file provided. + if file_urls: + text: str = file_urls.read_text() + urls = text.splitlines() + else: + print("Provide either URLs or a file containing URLs (one per line).") + + raise typer.Abort() + + return _download(ctx, urls) + + +@app_dl_fav.command( + name="tracks", + help="Download your favorite track collection.", +) +def download_fav_tracks(ctx: typer.Context) -> bool: + """Download your favorite track collection. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. + """ + # Method name + func_name_favorites: str = "tracks" + + return _download_fav_factory(ctx, func_name_favorites) + + +@app_dl_fav.command( + name="artists", + help="Download your favorite artist collection.", +) +def download_fav_artists(ctx: typer.Context) -> bool: + """Download your favorite artist collection. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. + """ + # Method name + func_name_favorites: str = "artists" + + return _download_fav_factory(ctx, func_name_favorites) + + +@app_dl_fav.command( + name="albums", + help="Download your favorite album collection.", +) +def download_fav_albums(ctx: typer.Context) -> bool: + """Download your favorite album collection. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. + """ + # Method name + func_name_favorites: str = "albums" + + return _download_fav_factory(ctx, func_name_favorites) + + +@app_dl_fav.command( + name="videos", + help="Download your favorite video collection.", +) +def download_fav_videos(ctx: typer.Context) -> bool: + """Download your favorite video collection. + + Args: + ctx (typer.Context): Typer context object. + + Returns: + bool: Download result. + """ + # Method name + func_name_favorites: str = "videos" + + return _download_fav_factory(ctx, func_name_favorites) + + +def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: + """Factory which helps to download items from the favorites collections. + + Args: + ctx (typer.Context): Typer context object. + func_name_favorites (str): Method name to call from `tidalapi` favorites object. + + Returns: + bool: Download result. + """ + ctx.invoke(login, ctx) + func_favorites: Callable = getattr(ctx.obj[CTX_TIDAL].session.user.favorites, func_name_favorites) + media_urls: list[str] = [media.share_url for media in func_favorites()] + return _download(ctx, media_urls, try_login=False) + + +@app.command() +def gui(ctx: typer.Context): + """Launch the GUI for the application. + + Args: + ctx (typer.Context): Typer context object. + """ + from tidal_dl_ng.gui import gui_activate + + ctx.invoke(login, ctx) + gui_activate(ctx.obj[CTX_TIDAL]) + + +def handle_sigint_term(signum, frame): + """Set app abort event, so threads can check it and shutdown. + + Args: + signum: Signal number. + frame: Current stack frame. + """ + handling_app: HandlingApp = HandlingApp() + + handling_app.event_abort.set() + + +if __name__ == "__main__": + # Catch CTRL+C + signal.signal(signal.SIGINT, handle_sigint_term) + signal.signal(signal.SIGTERM, handle_sigint_term) + + # Check if the first argument is a URL. Hacky solution, since Typer does not support positional arguments without options / commands. + if len(sys.argv) > 1: + first_arg = sys.argv[1] + parsed_url = urlparse(first_arg) + + if parsed_url.scheme in ["http", "https"] and parsed_url.netloc: + # Rewrite sys.argv to simulate `dl ` + sys.argv.insert(1, "dl") + + app() diff --git a/tidal_dl_ng/config.py b/tidal_dl_ng/config.py new file mode 100644 index 0000000..ad11c73 --- /dev/null +++ b/tidal_dl_ng/config.py @@ -0,0 +1,293 @@ +import contextlib +import json +import os +import shutil +from collections.abc import Callable +from json import JSONDecodeError +from pathlib import Path +from threading import Event, Lock +from typing import Any + +import tidalapi + +from tidal_dl_ng.constants import ( + ATMOS_CLIENT_ID, + ATMOS_CLIENT_SECRET, + ATMOS_REQUEST_QUALITY, +) +from tidal_dl_ng.helper.decorator import SingletonMeta +from tidal_dl_ng.helper.path import path_config_base, path_file_settings, path_file_token +from tidal_dl_ng.model.cfg import Settings as ModelSettings +from tidal_dl_ng.model.cfg import Token as ModelToken + + +class BaseConfig: + data: ModelSettings | ModelToken + file_path: str + cls_model: ModelSettings | ModelToken + path_base: str = path_config_base() + + def save(self, config_to_compare: str = None) -> None: + data_json = self.data.to_json() + + # If old and current config is equal, skip the write operation. + if config_to_compare == data_json: + return + + # Try to create the base folder. + os.makedirs(self.path_base, exist_ok=True) + + with open(self.file_path, encoding="utf-8", mode="w") as f: + # Save it in a pretty format + obj_json_config = json.loads(data_json) + json.dump(obj_json_config, f, indent=4) + + def set_option(self, key: str, value: Any) -> None: + value_old: Any = getattr(self.data, key) + + if type(value_old) == bool: # noqa: E721 + value = True if value.lower() in ("true", "1", "yes", "y") else False # noqa: SIM210 + elif type(value_old) == int and type(value) != int: # noqa: E721 + value = int(value) + + setattr(self.data, key, value) + + def read(self, path: str) -> bool: + result: bool = False + settings_json: str = "" + + try: + with open(path, encoding="utf-8") as f: + settings_json = f.read() + + self.data = self.cls_model.from_json(settings_json) + result = True + except (JSONDecodeError, TypeError, FileNotFoundError, ValueError) as e: + if isinstance(e, ValueError): + path_bak = path + ".bak" + + # First check if a backup file already exists. If yes, remove it. + if os.path.exists(path_bak): + os.remove(path_bak) + + # Move the invalid config file to the backup location. + shutil.move(path, path_bak) + print( + "Something is wrong with your config. Maybe it is not compatible anymore due to a new app version." + f" You can find a backup of your old config here: '{path_bak}'. A new default config was created." + ) + + self.data = self.cls_model() + + # Call save in case of we need to update the saved config, due to changes in code. + self.save(settings_json) + + return result + + +class Settings(BaseConfig, metaclass=SingletonMeta): + def __init__(self): + self.cls_model = ModelSettings + self.file_path = path_file_settings() + self.read(self.file_path) + + +class Tidal(BaseConfig, metaclass=SingletonMeta): + session: tidalapi.Session + token_from_storage: bool = False + settings: Settings + is_pkce: bool + + def __init__(self, settings: Settings = None): + self.cls_model = ModelToken + tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000) + self.session = tidalapi.Session(tidal_config) + self.original_client_id = self.session.config.client_id + self.original_client_secret = self.session.config.client_secret + # Lock to ensure session-switching is thread-safe. + # This lock protects against a race condition where one thread + # changes the session credentials while another is using them. + # It is intentionally held by Download._get_stream_info + # for the *entire* duration of the credential switch AND + # the get_stream() call. + self.stream_lock = Lock() + # State-tracking flag to prevent redundant, expensive + # session re-authentication when the session is already in the + # correct mode (Atmos or Normal). + self.is_atmos_session = False + # self.session.config.client_id = "km8T1xS355y7dd3H" + # self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI=" + self.file_path = path_file_token() + self.token_from_storage = self.read(self.file_path) + + if settings: + self.settings = settings + self.settings_apply() + + def settings_apply(self, settings: Settings = None) -> bool: + if settings: + self.settings = settings + + if not self.is_atmos_session: + self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio) + self.session.video_quality = tidalapi.VideoQuality.high + + return True + + def login_token(self, do_pkce: bool = False) -> bool: + result = False + self.is_pkce = do_pkce + + if self.token_from_storage: + try: + result = self.session.load_oauth_session( + self.data.token_type, + self.data.access_token, + self.data.refresh_token, + self.data.expiry_time, + is_pkce=do_pkce, + ) + except Exception: + result = False + # Remove token file. Probably corrupt or invalid. + if os.path.exists(self.file_path): + os.remove(self.file_path) + + print( + "Either there is something wrong with your credentials / account or some server problems on TIDALs " + "side. Anyway... Try to login again by re-starting this app." + ) + + return result + + def login_finalize(self) -> bool: + result = self.session.check_login() + + if result: + self.token_persist() + + return result + + def token_persist(self) -> None: + self.set_option("token_type", self.session.token_type) + self.set_option("access_token", self.session.access_token) + self.set_option("refresh_token", self.session.refresh_token) + self.set_option("expiry_time", self.session.expiry_time) + self.save() + + # Set restrictive permissions on token file (Unix-based systems only) + with contextlib.suppress(OSError, NotImplementedError): + os.chmod(self.file_path, 0o600) + + def switch_to_atmos_session(self) -> bool: + """ + Switches the shared session to Dolby Atmos credentials. + Only re-authenticates if not already in Atmos mode. + + Returns: + bool: True if successful or already in Atmos mode, False otherwise. + """ + # If we are already in Atmos mode, do nothing. + if self.is_atmos_session: + return True + + print("Switching session context to Dolby Atmos...") + self.session.config.client_id = ATMOS_CLIENT_ID + self.session.config.client_secret = ATMOS_CLIENT_SECRET + self.session.audio_quality = ATMOS_REQUEST_QUALITY + + # Re-login with new credentials + if not self.login_token(do_pkce=self.is_pkce): + print("Warning: Atmos session authentication failed.") + # Try to switch back to normal to be safe + self.restore_normal_session(force=True) + return False + + self.is_atmos_session = True # Set the flag + print("Session is now in Atmos mode.") + return True + + def restore_normal_session(self, force: bool = False) -> bool: + """ + Restores the shared session to the original user credentials. + Only re-authenticates if not already in Normal mode. + + Args: + force: If True, forces restoration even if already in Normal mode. + + Returns: + bool: True if successful or already in Normal mode, False otherwise. + """ + # If we are already in Normal mode (and not forced), do nothing. + if not self.is_atmos_session and not force: + return True + + print("Restoring session context to Normal...") + self.session.config.client_id = self.original_client_id + self.session.config.client_secret = self.original_client_secret + + # Explicitly restore audio quality to user's configured setting + self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio) + + # Re-login with original credentials + if not self.login_token(do_pkce=self.is_pkce): + print("Warning: Restoring the original session context failed. Please restart the application.") + return False + + self.is_atmos_session = False # Set the flag + print("Session is now in Normal mode.") + return True + + def login(self, fn_print: Callable) -> bool: + is_token = self.login_token() + result = False + + if is_token: + fn_print("Yep, looks good! You are logged in.") + + result = True + elif not is_token: + fn_print("You either do not have a token or your token is invalid.") + fn_print("No worries, we will handle this...") + # Login method: Device linking + self.session.login_oauth_simple(fn_print) + # Login method: PKCE authorization (was necessary for HI_RES_LOSSLESS streaming earlier) + # self.session.login_pkce(fn_print) + + is_login = self.login_finalize() + + if is_login: + fn_print("The login was successful. I have stored your credentials (token).") + + result = True + else: + fn_print("Something went wrong. Did you login using your browser correctly? May try again...") + + return result + + def logout(self): + Path(self.file_path).unlink(missing_ok=True) + self.token_from_storage = False + del self.session + + return True + + def is_authentication_error(self, error: Exception) -> bool: + """Check if an error is related to authentication/OAuth issues. + + Args: + error (Exception): The exception to check. + + Returns: + bool: True if the error is authentication-related, False otherwise. + """ + error_msg = str(error) + return "401" in error_msg or "OAuth" in error_msg or "token" in error_msg.lower() + + +class HandlingApp(metaclass=SingletonMeta): + event_abort: Event = Event() + event_run: Event = Event() + + def __init__(self): + self.event_run.set() diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py new file mode 100644 index 0000000..696b62a --- /dev/null +++ b/tidal_dl_ng/constants.py @@ -0,0 +1,97 @@ +import base64 +from enum import StrEnum + +from tidalapi import Quality + +CTX_TIDAL: str = "tidal" +REQUESTS_TIMEOUT_SEC: int = 45 +EXTENSION_LYRICS: str = ".lrc" +UNIQUIFY_THRESHOLD: int = 99 +FILENAME_SANITIZE_PLACEHOLDER: str = "_" +COVER_NAME: str = "cover.jpg" +BLOCK_SIZE: int = 4096 +BLOCKS: int = 1024 +CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS +PLAYLIST_EXTENSION: str = ".m3u" +PLAYLIST_PREFIX: str = "_" +FILENAME_LENGTH_MAX: int = 255 +FORMAT_TEMPLATE_EXPLICIT: str = " (Explicit)" +METADATA_EXPLICIT: str = " 🅴" + +# Dolby Atmos API credentials (obfuscated) +ATMOS_ID_B64 = "N203QX" + "AwSkM5aj" + "FjT00zbg==" +ATMOS_SECRET_B64 = "dlJBZEEx" + "MDh0bHZrSnB" + "Uc0daUzhyR1" + "o3eFRsYkow" + "cWFaMks5c2F" + "FenNnWT0=" + +ATMOS_CLIENT_ID = base64.b64decode(ATMOS_ID_B64).decode("utf-8") +ATMOS_CLIENT_SECRET = base64.b64decode(ATMOS_SECRET_B64).decode("utf-8") +ATMOS_REQUEST_QUALITY = Quality.low_320k + + +class QualityVideo(StrEnum): + P360 = "360" + P480 = "480" + P720 = "720" + P1080 = "1080" + + +class MediaType(StrEnum): + TRACK = "track" + VIDEO = "video" + PLAYLIST = "playlist" + ALBUM = "album" + MIX = "mix" + ARTIST = "artist" + + +class CoverDimensions(StrEnum): + Px80 = "80" + Px160 = "160" + Px320 = "320" + Px640 = "640" + Px1280 = "1280" + PxORIGIN = "origin" + + +class TidalLists(StrEnum): + Playlists = "Playlists" + Favorites = "Favorites" + Mixes = "Mixes" + + +class QueueDownloadStatus(StrEnum): + Waiting = "⏳️" + Downloading = "▶️" + Finished = "✅" + Failed = "❌" + Skipped = "↪️" + + +FAVORITES: dict[str, dict[str, str]] = { + "fav_videos": {"name": "Videos", "function_name": "videos"}, + "fav_tracks": {"name": "Tracks", "function_name": "tracks_paginated"}, + "fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"}, + "fav_artists": {"name": "Artists", "function_name": "artists_paginated"}, + "fav_albums": {"name": "Albums", "function_name": "albums_paginated"}, +} + + +class AudioExtensionsValid(StrEnum): + FLAC = ".flac" + M4A = ".m4a" + MP4 = ".mp4" + MP3 = ".mp3" + OGG = ".ogg" + ALAC = ".alac" + + +class MetadataTargetUPC(StrEnum): + UPC = "UPC" + BARCODE = "BARCODE" + EAN = "EAN" + + +METADATA_LOOKUP_UPC: dict[str, dict[str, str]] = { + "UPC": {"MP3": "UPC", "MP4": "UPC", "FLAC": "UPC"}, + "BARCODE": {"MP3": "BARCODE", "MP4": "BARCODE", "FLAC": "BARCODE"}, + "EAN": {"MP3": "EAN", "MP4": "EAN", "FLAC": "EAN"}, +} diff --git a/tidal_dl_ng/dialog.py b/tidal_dl_ng/dialog.py new file mode 100644 index 0000000..9a817f7 --- /dev/null +++ b/tidal_dl_ng/dialog.py @@ -0,0 +1,360 @@ +import datetime +import os +import os.path +import shutil +import webbrowser +from enum import StrEnum +from pathlib import Path + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Quality as QualityAudio + +from tidal_dl_ng import __version__ +from tidal_dl_ng.config import Settings +from tidal_dl_ng.constants import CoverDimensions, QualityVideo +from tidal_dl_ng.model.cfg import HelpSettings +from tidal_dl_ng.model.cfg import Settings as ModelSettings +from tidal_dl_ng.model.meta import ReleaseLatest +from tidal_dl_ng.ui.dialog_login import Ui_DialogLogin +from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings +from tidal_dl_ng.ui.dialog_version import Ui_DialogVersion + + +class DialogVersion(QtWidgets.QDialog): + """Version dialog.""" + + ui: Ui_DialogVersion + + def __init__( + self, parent=None, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None + ): + super().__init__(parent) + + # Create an instance of the GUI + self.ui = Ui_DialogVersion() + + # Run the .setupUi() method to show the GUI + self.ui.setupUi(self) + # Set the version. + self.ui.l_version.setText("v" + __version__) + + if not update_check: + self.update_info_hide() + self.error_hide() + else: + self.update_info(update_available, update_info) + + # Show + self.exec() + + def update_info(self, update_available: bool, update_info: ReleaseLatest): + if not update_available and update_info.version == "v0.0.0": + self.update_info_hide() + self.ui.l_error_details.setText( + "Cannot retrieve update information. Maybe something is wrong with your internet connection." + ) + else: + self.error_hide() + + if not update_available: + self.ui.l_h_version_new.setText("Latest available version:") + self.changelog_hide() + else: + self.ui.l_changelog_details.setText(update_info.release_info) + self.ui.pb_download.clicked.connect(lambda: webbrowser.open(update_info.url)) + + self.ui.l_version_new.setText(update_info.version) + + def error_hide(self): + self.ui.l_error.setHidden(True) + self.ui.l_error_details.setHidden(True) + + def update_info_hide(self): + self.ui.l_h_version_new.setHidden(True) + self.ui.l_version_new.setHidden(True) + self.changelog_hide() + + def changelog_hide(self): + self.ui.l_changelog.setHidden(True) + self.ui.l_changelog_details.setHidden(True) + self.ui.pb_download.setHidden(True) + + +class DialogLogin(QtWidgets.QDialog): + """Version dialog.""" + + ui: Ui_DialogLogin + url_redirect: str + + def __init__(self, url_login: str, hint: str, expires_in: int, parent=None): + super().__init__(parent) + + datetime_current: datetime.datetime = datetime.datetime.now() + datetime_expires: datetime.datetime = datetime_current + datetime.timedelta(0, expires_in) + + # Create an instance of the GUI + self.ui = Ui_DialogLogin() + + # Run the .setupUi() method to show the GUI + self.ui.setupUi(self) + # Set data. + self.ui.tb_url_login.setText(f'https://{url_login}') + self.ui.l_hint.setText(hint) + self.ui.l_expires_date_time.setText(datetime_expires.strftime("%Y-%m-%d %H:%M")) + # Show + self.return_code = self.exec() + + +class DialogPreferences(QtWidgets.QDialog): + """Preferences dialog (non-blocking, deferred population). + + The macOS Text Services Manager (TSM) can emit CFMessagePortSendRequest failures + when text widgets (e.g., QLineEdit) are populated before the window is fully + registered with the window server. To avoid this, heavy UI population is deferred + until after the dialog is shown via an overridden showEvent using a QTimer. + + All expensive or input-related operations (setting text, pixmaps) are performed + only once after first show to prevent premature IME/TSM activation. + """ + + ui: Ui_DialogSettings + settings: Settings + data: ModelSettings + s_settings_save: object # Accept any signal-like object (loosened for runtime SignalInstance) + help_settings: HelpSettings + parameters_checkboxes: list[str] + parameters_combo: list[tuple[str, StrEnum]] + parameters_line_edit: list[str] + parameters_spin_box: list[str] + prefix_checkbox: str = "cb_" + prefix_label: str = "l_" + prefix_icon: str = "icon_" + prefix_line_edit: str = "le_" + prefix_combo: str = "c_" + prefix_spin_box: str = "sb_" + _icon: QtGui.QIcon | None = None + _populated: bool = False + + def __init__(self, settings: Settings, settings_save: object, parent=None): + super().__init__(parent) + + self.settings = settings + self.data = settings.data + self.s_settings_save = settings_save + self.help_settings = HelpSettings() + + self._init_checkboxes() + self._init_comboboxes() + self._init_line_edit() + self._init_spin_box() + + # Create an instance of the GUI and perform lightweight UI setup only. + self.ui = Ui_DialogSettings() + self.ui.setupUi(self) + + # Non-blocking pattern: caller will invoke .show() / .open(); we do NOT call exec(). + # Heavy population deferred to showEvent. + + # ------------------------------ + # Internal helpers + # ------------------------------ + def ensure_main_thread(self) -> None: + """Ensure method runs on the main (GUI) thread.""" + app = QtWidgets.QApplication.instance() + if app and QtCore.QThread.currentThread() is not app.thread(): + raise RuntimeError + + @property + def icon(self) -> QtGui.QIcon: + """Lazy-create and cache the dialog icon.""" + if self._icon is None: + pixmapi: QtWidgets.QStyle.StandardPixmap = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxQuestion + self._icon = self.style().standardIcon(pixmapi) + return self._icon + + def showEvent(self, event: QtGui.QShowEvent) -> None: + """On first show, defer population to avoid macOS TSM early activation.""" + super().showEvent(event) + if not self._populated: + self._populated = True + # Slight delay on macOS to ensure window server registration. + delay_ms = 50 if os.name == "posix" and shutil.which("uname") and os.uname().sysname == "Darwin" else 0 + QtCore.QTimer.singleShot(delay_ms, self._deferred_populate) + + def _deferred_populate(self) -> None: + """Populate widgets after dialog is visible (safe for macOS TSM).""" + self.ensure_main_thread() + QtWidgets.QApplication.processEvents() + self.gui_populate() + + def _init_line_edit(self): + self.parameters_line_edit = [ + "download_base_path", + "format_album", + "format_playlist", + "format_mix", + "format_track", + "format_video", + "path_binary_ffmpeg", + ] + + def _init_spin_box(self): + self.parameters_spin_box = ["album_track_num_pad_min", "downloads_concurrent_max"] + + def _init_comboboxes(self): + self.parameters_combo = [ + ("quality_audio", QualityAudio), + ("quality_video", QualityVideo), + ("metadata_cover_dimension", CoverDimensions), + ] + + def _init_checkboxes(self): + self.parameters_checkboxes = [ + "lyrics_embed", + "lyrics_file", + "use_primary_album_artist", + "video_download", + "download_dolby_atmos", + "download_delay", + "video_convert_mp4", + "extract_flac", + "metadata_cover_embed", + "mark_explicit", + "cover_album_file", + "skip_existing", + "symlink_to_track", + "playlist_create", + ] + + def gui_populate(self): + self.populate_checkboxes() + self.populate_combo() + self.populate_line_edit() + self.populate_spin_box() + + def dialog_chose_file( + self, + obj_line_edit: QtWidgets.QLineEdit, + file_mode: QtWidgets.QFileDialog.FileMode = QtWidgets.QFileDialog.FileMode.Directory, + path_default: str | None = None, + ) -> None: + # If a path is set, use it otherwise the users home directory. + path_settings: str = os.path.expanduser(obj_line_edit.text()) if obj_line_edit.text() else "" + # Check if obj_line_edit is empty but path_default can be used instead + if not path_settings and path_default: + expanded_default = os.path.expanduser(path_default) + path_settings = expanded_default + dir_current: str = path_settings if path_settings and os.path.exists(path_settings) else str(Path.home()) + dialog: QtWidgets.QFileDialog = QtWidgets.QFileDialog() + + # Set to directory mode only but show files. + dialog.setFileMode(file_mode) + dialog.setViewMode(QtWidgets.QFileDialog.ViewMode.Detail) + dialog.setOption(QtWidgets.QFileDialog.Option.ShowDirsOnly, False) + dialog.setOption(QtWidgets.QFileDialog.Option.DontResolveSymlinks, True) + + # There is a bug in the PyQt implementation, which hides files in Directory mode. + # Thus, we need to use the PyQt dialog instead of the native dialog. + if os.name == "nt" and file_mode == QtWidgets.QFileDialog.Directory: + dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) + + dialog.setDirectory(dir_current) + + # Execute dialog and set path if something is chosen. + if dialog.exec(): + dir_name: str = dialog.selectedFiles()[0] + path: Path = Path(dir_name) + obj_line_edit.setText(str(path)) + + def populate_line_edit(self): + for pn in self.parameters_line_edit: + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + line_edit: QtWidgets.QLineEdit = getattr(self.ui, self.prefix_line_edit + pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + # Safe line edit updates (suppress signals/UI updates during bulk setText) + line_edit.blockSignals(True) + line_edit.setUpdatesEnabled(False) + line_edit.setText(str(getattr(self.data, pn))) + line_edit.setUpdatesEnabled(True) + line_edit.blockSignals(False) + + # Base Path File Dialog + self.ui.pb_download_base_path.clicked.connect(lambda x: self.dialog_chose_file(self.ui.le_download_base_path)) + # Defer shutil.which() call to prevent TSM errors during initialization + self.ui.pb_path_binary_ffmpeg.clicked.connect( + lambda x: self.dialog_chose_file( + self.ui.le_path_binary_ffmpeg, + file_mode=QtWidgets.QFileDialog.FileMode.ExistingFiles, + path_default=self._get_ffmpeg_path(), + ) + ) + + def populate_combo(self): + for pn, values in self.parameters_combo: + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + combo: QtWidgets.QComboBox = getattr(self.ui, self.prefix_combo + pn) + setting_current = getattr(self.data, pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + + for index, v in enumerate(list(values)): + combo.addItem(v.name, v) + if v == setting_current: + combo.setCurrentIndex(index) + + def populate_checkboxes(self): + for pn in self.parameters_checkboxes: + checkbox: QtWidgets.QCheckBox = getattr(self.ui, self.prefix_checkbox + pn) + checkbox.setText(pn) + checkbox.setToolTip(getattr(self.help_settings, pn)) + checkbox.setIcon(self.icon) + checkbox.setChecked(getattr(self.data, pn)) + + def populate_spin_box(self): + for pn in self.parameters_spin_box: + label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) + label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) + spin_box: QtWidgets.QSpinBox = getattr(self.ui, self.prefix_spin_box + pn) + + label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) + label_icon.setToolTip(getattr(self.help_settings, pn)) + label.setText(pn) + spin_box.setValue(getattr(self.data, pn)) + + def accept(self): + # Get settings. + self.to_settings() + self.done(1) + + def _get_ffmpeg_path(self) -> str | None: + """Get the ffmpeg binary path using shutil.which. + + This method is called only when needed (when button is clicked), + not during initialization, to prevent TSM errors on macOS. + + Returns: + str | None: Path to ffmpeg binary or None if not found. + """ + return shutil.which("ffmpeg") + + def to_settings(self): + for item in self.parameters_checkboxes: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_checkbox + item).isChecked()) + + for item in self.parameters_line_edit: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_line_edit + item).text()) + + for item in self.parameters_combo: + setattr(self.settings.data, item[0], getattr(self.ui, self.prefix_combo + item[0]).currentData()) + + for item in self.parameters_spin_box: + setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value()) + + self.s_settings_save.emit() diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py new file mode 100644 index 0000000..c26a33d --- /dev/null +++ b/tidal_dl_ng/download.py @@ -0,0 +1,1787 @@ +""" +download.py + +Implements the Download class and helpers for downloading media from TIDAL, including segment merging, file moving, metadata writing, and playlist creation. + +Classes: + RequestsClient: Simple HTTP client for downloading text content. + Download: Main class for managing downloads, segment merging, file operations, and metadata. +""" + +import os +import pathlib +import random +import shutil +import tempfile +import time +from collections.abc import Callable +from concurrent import futures +from threading import Event +from uuid import uuid4 + +import m3u8 +import requests +from ffmpeg import FFmpeg +from pathvalidate import sanitize_filename +from requests.adapters import HTTPAdapter, Retry +from requests.exceptions import HTTPError +from rich.progress import Progress, TaskID +from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video +from tidalapi.exceptions import TooManyRequests +from tidalapi.media import ( + AudioExtensions, + AudioMode, + Codec, + Quality, + Stream, + StreamManifest, + VideoExtensions, +) + +from tidal_dl_ng.config import Settings, Tidal +from tidal_dl_ng.constants import ( + CHUNK_SIZE, + COVER_NAME, + EXTENSION_LYRICS, + METADATA_EXPLICIT, + METADATA_LOOKUP_UPC, + PLAYLIST_EXTENSION, + PLAYLIST_PREFIX, + REQUESTS_TIMEOUT_SEC, + AudioExtensionsValid, + CoverDimensions, + MediaType, + MetadataTargetUPC, + QualityVideo, +) +from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token +from tidal_dl_ng.helper.exceptions import MediaMissing +from tidal_dl_ng.helper.path import ( + check_file_exists, + format_path_media, + path_file_sanitize, + url_to_filename, +) +from tidal_dl_ng.helper.tidal import ( + instantiate_media, + items_results_all, + name_builder_album_artist, + name_builder_artist, + name_builder_item, + name_builder_title, +) +from tidal_dl_ng.metadata import Metadata +from tidal_dl_ng.model.downloader import DownloadSegmentResult, TrackStreamInfo +from tidal_dl_ng.model.gui_data import ProgressBars + + +# TODO: Set appropriate client string and use it for video download. +# https://github.com/globocom/m3u8#using-different-http-clients +class RequestsClient: + """HTTP client for downloading text content from a URI.""" + + def download( + self, uri: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True + ) -> tuple[str, str]: + """Download the content of a URI as text. + + Args: + uri (str): The URI to download. + timeout (int, optional): Timeout in seconds. Defaults to REQUESTS_TIMEOUT_SEC. + headers (dict | None, optional): HTTP headers. Defaults to None. + verify_ssl (bool, optional): Whether to verify SSL. Defaults to True. + + Returns: + tuple[str, str]: Tuple of (text content, final URL). + """ + if not headers: + headers = {} + + o = requests.get(uri, timeout=timeout, headers=headers) + o.raise_for_status() + + return o.text, o.url + + +# TODO: Use pathlib.Path everywhere +class Download: + """Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media.""" + + settings: Settings + tidal: "Tidal" + session: Session + skip_existing: bool = False + fn_logger: Callable + progress_gui: ProgressBars + progress: Progress + progress_overall: Progress + event_abort: Event + event_run: Event + + def __init__( + self, + tidal_obj: Tidal, # Required for Atmos session context manager + path_base: str, + fn_logger: Callable, + skip_existing: bool = False, + progress_gui: ProgressBars | None = None, + progress: Progress | None = None, + progress_overall: Progress | None = None, + event_abort: Event | None = None, + event_run: Event | None = None, + ) -> None: + """Initialize the Download object and its dependencies. + + Args: + tidal_obj (Tidal): TIDAL configuration object. Required for: + - session: Main TIDAL API session + - switch_to_atmos_session(): Dolby Atmos credential switching + - restore_normal_session(): Restore original session credentials + path_base (str): Base path for downloads. + fn_logger (Callable): Logger function or object. + skip_existing (bool, optional): Whether to skip existing files. Defaults to False. + progress_gui (ProgressBars | None, optional): GUI progress bars. Defaults to None. + progress (Progress | None, optional): Rich progress bar. Defaults to None. + progress_overall (Progress | None, optional): Overall progress bar. Defaults to None. + event_abort (Event | None, optional): Abort event. Defaults to None. + event_run (Event | None, optional): Run event. Defaults to None. + """ + self.settings = Settings() + self.tidal = tidal_obj + self.session = tidal_obj.session + self.skip_existing = skip_existing + self.fn_logger = fn_logger + self.progress_gui = progress_gui + self.progress = progress + self.progress_overall = progress_overall + self.path_base = path_base + self.event_abort = event_abort + self.event_run = event_run + + if not self.settings.data.path_binary_ffmpeg and ( + self.settings.data.video_convert_mp4 or self.settings.data.extract_flac + ): + self.settings.data.video_convert_mp4 = False + self.settings.data.extract_flac = False + + self.fn_logger.error( + "FFmpeg path is not set. Videos can be downloaded but will not be processed. FLAC cannot be " + "extracted from MP4 containers. Make sure FFmpeg is installed. The path to the FFmpeg binary must " + "be set in (`path_binary_ffmpeg`)." + ) + + def _get_media_urls( + self, + media: Track | Video, + stream_manifest: StreamManifest | None = None, + ) -> list[str]: + """Extract URLs for the given media item. + + Args: + media (Track | Video): The media item to download. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + list[str]: List of URLs for the media segments. + """ + # Get urls for media. + if isinstance(media, Track): + return stream_manifest.get_urls() + elif isinstance(media, Video): + quality_video = self.settings.data.quality_video + m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url()) + # Find the desired video resolution or the next best one. + m3u8_playlist, _ = self._extract_video_stream(m3u8_variant, int(quality_video)) + + return m3u8_playlist.files + else: + return [] + + def _setup_progress( + self, + media_name: str, + urls: list[str], + progress_to_stdout: bool, + ) -> tuple[TaskID, int | float | None, int | None]: + """Set up the progress bar/task and compute progress total and block size. + + Args: + media_name (str): Name of the media item. + urls (list[str]): List of segment URLs. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + tuple[TaskID, int | float | None, int | None]: (TaskID, progress_total, block_size) + """ + urls_count: int = len(urls) + progress_total: int | float | None = None + block_size: int | None = None + + # Compute total iterations for progress + if urls_count > 1: + progress_total: int = urls_count + block_size: int | None = None + elif urls_count == 1: + r = None + try: + # Get file size and compute progress steps + r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC) + r.raise_for_status() + + total_size_in_bytes: int = int(r.headers.get("content-length", 0)) + block_size = 1048576 + progress_total = total_size_in_bytes / block_size + finally: + if r: + r.close() + else: + raise ValueError + + # Create progress Task + p_task: TaskID = self.progress.add_task( + f"[blue]Item '{media_name[:30]}'", + total=progress_total, + visible=progress_to_stdout, + ) + return p_task, progress_total, block_size + + def _download_segments( + self, + urls: list[str], + path_base: pathlib.Path, + block_size: int | None, + p_task: TaskID, + progress_to_stdout: bool, + ) -> tuple[bool, list[DownloadSegmentResult]]: + """Download all segments with progress reporting and abort handling. + + Args: + urls (list[str]): List of segment URLs. + path_base (pathlib.Path): Base path for segment files. + block_size (int | None): Block size for streaming. + p_task (TaskID): Progress bar task ID. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + tuple[bool, list[DownloadSegmentResult]]: (result_segments, list of segment results) + """ + result_segments: bool = True + dl_segment_results: list[DownloadSegmentResult] = [] + + # Download segments until progress is finished. + # TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py) + while not self.progress.tasks[p_task].finished: + with futures.ThreadPoolExecutor( + max_workers=self.settings.data.downloads_simultaneous_per_track_max + ) as executor: + # Dispatch all download tasks to worker threads + l_futures: list[futures.Future] = [ + executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout) + for url in urls + ] + + # Report results as they become available + for future in futures.as_completed(l_futures): + # Retrieve result + result_dl_segment: DownloadSegmentResult = future.result() + + dl_segment_results.append(result_dl_segment) + + # Check for a link that was skipped + if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]): + # Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is + # invalid (HTTP Error 500) and not necessary. File won't be corrupt. + # If this is NOT the case, but any other URL has resulted in an error, + # mark the whole thing as corrupt. + result_segments = False + + self.fn_logger.error("Something went wrong while downloading. File is corrupt!") + + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + # Cancel all not yet started tasks + for f in l_futures: + f.cancel() + + return False, dl_segment_results + + return result_segments, dl_segment_results + + def _download_postprocess( + self, + result_segments: bool, + path_file: pathlib.Path, + dl_segment_results: list[DownloadSegmentResult], + media: Track | Video, + stream_manifest: StreamManifest | None = None, + ) -> tuple[bool, pathlib.Path]: + """Merge segments, decrypt if needed, and return the final file path. + + Args: + result_segments (bool): Whether all segments downloaded successfully. + path_file (pathlib.Path): Path to the output file. + dl_segment_results (list[DownloadSegmentResult]): List of segment download results. + media (Track | Video): The media item. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + """ + tmp_path_file_decrypted: pathlib.Path = path_file + result_merge: bool = False + + # Only if no error happened while downloading. + if result_segments: + # Bring list into right order, so segments can be easily merged. + dl_segment_results.sort(key=lambda x: x.id_segment) + + result_merge = self._segments_merge(path_file, dl_segment_results) + + if not result_merge: + self.fn_logger.error(f"Something went wrong while writing to {media.name}. File is corrupt!") + elif isinstance(media, Track) and stream_manifest.is_encrypted: + key, nonce = decrypt_security_token(stream_manifest.encryption_key) + tmp_path_file_decrypted = path_file.with_suffix(".decrypted") + + decrypt_file(path_file, tmp_path_file_decrypted, key, nonce) + + return result_merge, tmp_path_file_decrypted + + def _download( + self, + media: Track | Video, + path_file: pathlib.Path, + stream_manifest: StreamManifest | None = None, + ) -> tuple[bool, pathlib.Path]: + """Download a media item (track or video), handling segments and merging. + + Args: + media (Track | Video): The media item to download. + path_file (pathlib.Path): Path to the output file. + stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None. + + Returns: + tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file) + """ + media_name: str = name_builder_item(media) + + try: + urls: list[str] = self._get_media_urls(media, stream_manifest) + except Exception: + return False, path_file + + # Set the correct progress output channel. + if self.progress_gui is None: + progress_to_stdout: bool = True + else: + progress_to_stdout: bool = False + # Send signal to GUI with media name + self.progress_gui.item_name.emit(media_name[:30]) + + try: + p_task, progress_total, block_size = self._setup_progress(media_name, urls, progress_to_stdout) + except Exception: + return False, path_file + + result_segments, dl_segment_results = self._download_segments( + urls, path_file.parent, block_size, p_task, progress_to_stdout + ) + + result_merge, tmp_path_file_decrypted = self._download_postprocess( + result_segments, path_file, dl_segment_results, media, stream_manifest + ) + + return result_merge, tmp_path_file_decrypted + + def _segments_merge(self, path_file: pathlib.Path, dl_segment_results: list[DownloadSegmentResult]) -> bool: + """Merge downloaded segments into a single file and clean up segment files. + + Args: + path_file (pathlib.Path): Path to the output file. + dl_segment_results (list[DownloadSegmentResult]): List of segment download results. + + Returns: + bool: True if merge succeeded, False otherwise. + """ + result: bool = True + + # Copy the content of all segments into one file. + try: + with path_file.open("wb") as f_target: + for dl_segment_result in dl_segment_results: + with dl_segment_result.path_segment.open("rb") as f_segment: + # Read and write chunks, which gives better HDD write performance + while segment := f_segment.read(CHUNK_SIZE): + f_target.write(segment) + + # Delete segment from HDD + dl_segment_result.path_segment.unlink() + + except Exception: + if dl_segment_result is not dl_segment_results[-1]: + result = False + + return result + + def _download_segment( + self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool + ) -> DownloadSegmentResult: + """Download a single segment of a media file. + + Args: + url (str): URL of the segment. + path_base (pathlib.Path): Base path for segment file. + block_size (int | None): Block size for streaming. + p_task (TaskID): Progress bar task ID. + progress_to_stdout (bool): Whether to show progress in stdout. + + Returns: + DownloadSegmentResult: Result of the segment download. + """ + result: bool = False + path_segment: pathlib.Path = path_base / url_to_filename(url) + # Calculate the segment ID based on the file name within the URL. + filename_stem: str = str(path_segment.stem).split("_")[-1] + # CAUTION: This is a workaround, so BTS (LOW quality) track will work. They usually have only ONE link. + id_segment: int = int(filename_stem) if filename_stem.isdecimal() else 0 + error: HTTPError | None = None + + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + return DownloadSegmentResult( + result=False, url=url, path_segment=path_segment, id_segment=id_segment, error=error + ) + + if not self.event_run.is_set(): + self.event_run.wait() + + # Retry download on failed segments, with an exponential delay between retries + with requests.Session() as s: + retries = Retry(total=5, backoff_factor=1) # , status_forcelist=[ 502, 503, 504 ]) + + s.mount("https://", HTTPAdapter(max_retries=retries)) + + try: + # Create the request object with stream=True, so the content won't be loaded into memory at once. + r = s.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC) + + r.raise_for_status() + + # Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once. + with path_segment.open("wb") as f: + for data in r.iter_content(chunk_size=block_size): + f.write(data) + # Advance progress bar. + self.progress.advance(p_task) + + result = True + except Exception: + self.progress.advance(p_task) + + # To send the progress to the GUI, we need to emit the percentage. + if not progress_to_stdout: + self.progress_gui.item.emit(self.progress.tasks[p_task].percentage) + + return DownloadSegmentResult( + result=result, url=url, path_segment=path_segment, id_segment=id_segment, error=error + ) + + def extension_guess( + self, quality_audio: Quality, metadata_tags: list[str], is_video: bool + ) -> AudioExtensions | VideoExtensions: + """Guess the file extension for a media item based on quality and type. + + Args: + quality_audio (Quality): Audio quality. + metadata_tags (list[str]): Metadata tags for the media. + is_video (bool): Whether the media is a video. + + Returns: + AudioExtensions | VideoExtensions: Guessed file extension. + """ + result: AudioExtensions | VideoExtensions + + if is_video: + result = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + else: + result = ( + AudioExtensions.FLAC + if len(metadata_tags) > 0 # If there are no metadata tags only lossy quality is available + and ( + ( + self.settings.data.extract_flac + and quality_audio in (Quality.hi_res_lossless, Quality.high_lossless) + ) + or ( + "HIRES_LOSSLESS" not in metadata_tags + and quality_audio not in (Quality.low_96k, Quality.low_320k) + ) + or quality_audio == Quality.high_lossless + ) + else AudioExtensions.M4A + ) + + return result + + def item( + self, + file_template: str, + media_id: str | None = None, + media_type: MediaType | None = None, + media: Track | Video | None = None, + video_download: bool = True, + download_delay: bool = False, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + is_parent_album: bool = False, + list_position: int = 0, + list_total: int = 0, + ) -> tuple[bool, pathlib.Path | str]: + """Download a single media item, handling file naming, skipping, and post-processing. + + Args: + file_template (str): Template for file naming. + media_id (str | None, optional): Media ID. Defaults to None. + media_type (MediaType | None, optional): Media type. Defaults to None. + media (Track | Video | None, optional): Media item. Defaults to None. + video_download (bool, optional): Whether to allow video downloads. Defaults to True. + download_delay (bool, optional): Whether to delay between downloads. Defaults to False. + quality_audio (Quality | None, optional): Audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Video quality. Defaults to None. + is_parent_album (bool, optional): Whether this is a parent album. Defaults to False. + list_position (int, optional): Position in list. Defaults to 0. + list_total (int, optional): Total items in list. Defaults to 0. + + Returns: + tuple[bool, pathlib.Path | str]: (Downloaded, path to file) + """ + # Step 1: Validate and prepare media + validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download) + if validated_media is None or not isinstance(validated_media, Track | Video): + return False, "" + + media = validated_media + + # Step 2: Create file paths and determine skip logic + path_media_dst, file_extension_dummy, skip_file, skip_download = self._prepare_file_paths_and_skip_logic( + media, file_template, quality_audio, list_position, list_total + ) + + if skip_file: + self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") + + return True, path_media_dst + + # Step 3: Handle quality settings + quality_audio_old, quality_video_old = self._adjust_quality_settings(quality_audio, quality_video) + + # Step 4: Download and process media + download_success = self._download_and_process_media( + media, path_media_dst, skip_download, is_parent_album, file_extension_dummy + ) + + # Step 5: Post-processing + self._perform_post_processing( + media, + path_media_dst, + quality_audio, + quality_video, + quality_audio_old, + quality_video_old, + download_delay, + skip_file, + ) + + return download_success, path_media_dst + + def _validate_and_prepare_media( + self, + media: Track | Video | Album | Playlist | UserPlaylist | Mix | None, + media_id: str | None, + media_type: MediaType | None, + video_download: bool = True, + ) -> Track | Video | Album | Playlist | UserPlaylist | Mix | None: + """Validate and prepare media instance for download. + + Args: + media (Track | Video | Album | Playlist | UserPlaylist | Mix | None): Media instance. + media_id (str | None): Media ID if creating new instance. + media_type (MediaType | None): Media type if creating new instance. + video_download (bool, optional): Whether video downloads are allowed. Defaults to True. + + Returns: + Track | Video | Album | Playlist | UserPlaylist | Mix | None: Prepared media instance or None if invalid. + """ + try: + if media_id and media_type: + # If no media instance is provided, we need to create the media instance. + # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore. + media = instantiate_media(self.session, media_type, media_id) + elif isinstance(media, Track | Video): + # Check if media is available not deactivated / removed from TIDAL. + if not media.available: + self.fn_logger.info( + f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}" + ) + return None + elif isinstance(media, Track): + # Re-create media instance with full album information + media = self.session.track(str(media.id), with_album=True) + elif isinstance(media, Album): + # Check if media is available not deactivated / removed from TIDAL. + if not media.available: + self.fn_logger.info( + f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}" + ) + return None + elif not media: + self._raise_media_missing() + except (MediaMissing, Exception): + return None + + # If video download is not allowed and this is a video, return None + if not video_download and isinstance(media, Video): + self.fn_logger.info( + f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}" + ) + return None + + return media + + def _raise_media_missing(self) -> None: + """Raise MediaMissing exception. + + Helper method to abstract raise statement as per TRY301. + """ + raise MediaMissing + + def _prepare_file_paths_and_skip_logic( + self, + media: Track | Video, + file_template: str, + quality_audio: Quality | None, + list_position: int, + list_total: int, + ) -> tuple[pathlib.Path, str, bool, bool]: + """Prepare file paths and determine skip logic. + + Args: + media (Track | Video): Media item. + file_template (str): Template for file naming. + quality_audio (Quality | None): Audio quality setting. + list_position (int): Position in list. + list_total (int): Total items in list. + + Returns: + tuple[pathlib.Path, str, bool, bool]: (path_media_dst, file_extension_dummy, skip_file, skip_download) + """ + # Create file name and path + metadata_tags = [] if isinstance(media, Video) else (media.media_metadata_tags or []) + quality_for_extension = quality_audio if quality_audio is not None else Quality.high_lossless + + file_extension_dummy: str = self.extension_guess( + quality_for_extension, + metadata_tags=metadata_tags, + is_video=isinstance(media, Video), + ) + + file_name_relative: str = format_path_media( + file_template, + media, + self.settings.data.album_track_num_pad_min, + list_position, + list_total, + delimiter_artist=self.settings.data.filename_delimiter_artist, + delimiter_album_artist=self.settings.data.filename_delimiter_album_artist, + use_primary_album_artist=self.settings.data.use_primary_album_artist, + ) + + path_media_dst: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy) + ).absolute() + + # Sanitize final path_file to fit into OS boundaries. + path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True)) + + # Compute if and how downloads need to be skipped. + skip_download: bool = False + + if self.skip_existing: + skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False) + + if self.settings.data.symlink_to_track and not isinstance(media, Video): + # Compute symlink tracks path, sanitize and check if file exists + file_name_track_dir_relative: str = format_path_media( + self.settings.data.format_track, + media, + delimiter_artist=self.settings.data.filename_delimiter_artist, + delimiter_album_artist=self.settings.data.filename_delimiter_album_artist, + use_primary_album_artist=self.settings.data.use_primary_album_artist, + ) + path_media_track_dir: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_track_dir_relative + file_extension_dummy) + ).absolute() + path_media_track_dir = pathlib.Path(path_file_sanitize(path_media_track_dir, adapt=True)) + file_exists_track_dir: bool = check_file_exists(path_media_track_dir, extension_ignore=False) + file_exists_playlist_dir: bool = ( + not file_exists_track_dir and skip_file and not path_media_dst.is_symlink() + ) + skip_download = file_exists_playlist_dir or file_exists_track_dir + + # If file exists in playlist dir but not in track dir, we don't skip the file itself + if skip_file and file_exists_playlist_dir: + skip_file = False + else: + skip_file: bool = False + + return path_media_dst, file_extension_dummy, skip_file, skip_download + + def _adjust_quality_settings( + self, quality_audio: Quality | None, quality_video: QualityVideo | None + ) -> tuple[Quality | None, QualityVideo | None]: + """Adjust quality settings and return previous values. + + Args: + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + + Returns: + tuple[Quality | None, QualityVideo | None]: Previous quality settings. + """ + quality_audio_old: Quality | None = None + quality_video_old: QualityVideo | None = None + + if quality_audio: + quality_audio_old = self.adjust_quality_audio(quality_audio) + + if quality_video: + quality_video_old = self.adjust_quality_video(quality_video) + + return quality_audio_old, quality_video_old + + def _download_and_process_media( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + skip_download: bool, + is_parent_album: bool, + file_extension_dummy: str, + ) -> bool: + """Download and process media file. + + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + skip_download (bool): Whether to skip download. + is_parent_album (bool): Whether this is a parent album. + file_extension_dummy (str): Dummy file extension. + + """ + """ + if skip_download: + return True + + # Get stream information and final file extension + stream_manifest, file_extension, do_flac_extract, media_stream = self._get_stream_info(media) + + if stream_manifest is None and isinstance(media, Track): + return False + + # Update path if extension changed + if path_media_dst.suffix != file_extension: + path_media_dst = path_media_dst.with_suffix(file_extension) + path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True)) + + os.makedirs(path_media_dst.parent, exist_ok=True) + + # Perform actual download + return self._perform_actual_download( + media, path_media_dst, stream_manifest, do_flac_extract, is_parent_album, media_stream + ) + + def _get_stream_info(self, media: Track | Video) -> tuple[StreamManifest | None, str, bool, Stream | None]: + """Get stream information for media. + + Args: + media (Track | Video): Media item. + + Returns: + tuple[StreamManifest | None, str, bool, Stream | None]: Stream info. + """ + stream_manifest: StreamManifest | None = None + media_stream: Stream | None = None + do_flac_extract: bool = False + file_extension: str = "" + + # CRITICAL: This lock is intentionally broad and serializes all + # stream-fetching (Phase 1) to prevent a critical race condition. + # + # THE PROBLEM: + # The single, shared session (self.tidal.session) must change its + # credentials to switch between Atmos and Hi-Res/Normal streams. + # + # THE RACE CONDITION IT FIXES: + # If this lock is released *before* get_stream() is called, + # another thread could change the session (e.g., back to "Normal") + # right after this thread switched it to "Atmos". This would + # cause this thread to call get_stream() with the wrong credentials, + # resulting in the API returning AAC 320 instead of Atmos. + # + # THE TRADEOFF: + # This creates a "tollbooth" bottleneck, serializing the get_stream() + # calls. However, the *actual* segment downloads (Phase 2) + # still run in parallel, governed by `downloads_concurrent_max`. + # + # DO NOT "OPTIMIZE" THIS by making the lock more granular. + # Correctness > Performance. + # + + with self.tidal.stream_lock: + try: + if isinstance(media, Track): + track_info = self._get_track_stream_info(media) + + if track_info.stream_manifest is None: + return None, "", False, None + + stream_manifest = track_info.stream_manifest + file_extension = track_info.file_extension + do_flac_extract = track_info.requires_flac_extraction + media_stream = track_info.media_stream + + elif isinstance(media, Video): + # Videos always require the normal session + if not self.tidal.restore_normal_session(): + self.fn_logger.error(f"Failed to restore normal session for video: {media.id}") + return None, "", False, None + + file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS + + stream_manifest = None + media_stream = None + do_flac_extract = False + + else: + self.fn_logger.error(f"Unknown media type for stream info: {type(media)}") + return None, "", False, None + + except TooManyRequests: + self.fn_logger.exception( + f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " + f"Consider to activate delay between downloads." + ) + return None, "", False, None + + except Exception: + self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.") + return None, "", False, None + + return stream_manifest, file_extension, do_flac_extract, media_stream + + def _get_track_stream_info(self, media: Track) -> TrackStreamInfo: + """ + Gets stream info for a Track, handling Atmos/Normal session switching. + + Args: + media: The track to get stream information for. + + Returns: + TrackStreamInfo: Container with stream manifest, file extension, + FLAC extraction flag, and media stream object. + Returns TrackStreamInfo with None/empty values if fails. + """ + want_atmos = ( + self.settings.data.download_dolby_atmos + and hasattr(media, "audio_modes") + and AudioMode.dolby_atmos.value in media.audio_modes + ) + + if want_atmos: + if not self.tidal.switch_to_atmos_session(): + self.fn_logger.error(f"Failed to switch to Atmos session for track: {media.id}") + return TrackStreamInfo(None, "", False, None) + else: + if not self.tidal.restore_normal_session(): + self.fn_logger.error(f"Failed to restore normal session for track: {media.id}") + return TrackStreamInfo(None, "", False, None) + + media_stream = self.session.track(media.id).get_stream() if want_atmos else media.get_stream() + + stream_manifest = media_stream.get_stream_manifest() + file_extension = stream_manifest.file_extension + requires_flac_extraction = False + + if self.settings.data.extract_flac and ( + stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC + ): + file_extension = AudioExtensions.FLAC + requires_flac_extraction = True + + return TrackStreamInfo( + stream_manifest=stream_manifest, + file_extension=file_extension, + requires_flac_extraction=requires_flac_extraction, + media_stream=media_stream, + ) + + def _perform_actual_download( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + stream_manifest: StreamManifest | None, + do_flac_extract: bool, + is_parent_album: bool, + media_stream: Stream | None, + ) -> bool: + """Perform the actual download and processing. + + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + stream_manifest (StreamManifest | None): Stream manifest. + do_flac_extract (bool): Whether to extract FLAC. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream | None): Media stream. + + Returns: + bool: Whether download was successful. + """ + # Create a temp directory and file. + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: + tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) + tmp_path_file.touch() + + # Download media. + result_download, tmp_path_file = self._download( + media=media, stream_manifest=stream_manifest, path_file=tmp_path_file + ) + + if not result_download: + return False + + # Convert video from TS to MP4 + if isinstance(media, Video) and self.settings.data.video_convert_mp4: + tmp_path_file = self._video_convert(tmp_path_file) + + # Extract FLAC from MP4 container using ffmpeg + if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract: + tmp_path_file = self._extract_flac(tmp_path_file) + + # Handle metadata, lyrics, and cover + self._handle_metadata_and_extras(media, tmp_path_file, path_media_dst, is_parent_album, media_stream) + + self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.") + + # Move final file to the configured destination directory. + shutil.move(tmp_path_file, path_media_dst) + + return True + + def _handle_metadata_and_extras( + self, + media: Track | Video, + tmp_path_file: pathlib.Path, + path_media_dst: pathlib.Path, + is_parent_album: bool, + media_stream: Stream | None, + ) -> None: + """Handle metadata, lyrics, and cover processing. + + Args: + media (Track | Video): Media item. + tmp_path_file (pathlib.Path): Temporary file path. + path_media_dst (pathlib.Path): Destination file path. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream | None): Media stream. + """ + if isinstance(media, Video): + return + + tmp_path_lyrics: pathlib.Path | None = None + tmp_path_cover: pathlib.Path | None = None + + # Write metadata to file. + if media_stream: + result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write( + media, tmp_path_file, is_parent_album, media_stream + ) + + # Move lyrics file + if self.settings.data.lyrics_file and tmp_path_lyrics: + self._move_lyrics(tmp_path_lyrics, path_media_dst) + + # Move cover file + if self.settings.data.cover_album_file and tmp_path_cover: + self._move_cover(tmp_path_cover, path_media_dst) + + def _perform_post_processing( + self, + media: Track | Video, + path_media_dst: pathlib.Path, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + quality_audio_old: Quality | None, + quality_video_old: QualityVideo | None, + download_delay: bool, + skip_file: bool, + ) -> None: + """Perform post-processing tasks. + + Args: + media (Track | Video): Media item. + path_media_dst (pathlib.Path): Destination file path. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + quality_audio_old (Quality | None): Previous audio quality. + quality_video_old (QualityVideo | None): Previous video quality. + download_delay (bool): Whether to apply download delay. + skip_file (bool): Whether file was skipped. + """ + # If files needs to be symlinked, do postprocessing here. + if self.settings.data.symlink_to_track and not isinstance(media, Video): + # Determine file extension for symlink + file_extension = path_media_dst.suffix + self.media_move_and_symlink(media, path_media_dst, file_extension) + + # Reset quality settings + if quality_audio_old is not None: + self.adjust_quality_audio(quality_audio_old) + + if quality_video_old is not None: + self.adjust_quality_video(quality_video_old) + + # Apply download delay if needed + if (download_delay and not skip_file) and not self.event_abort.is_set(): + time_sleep: float = round( + random.SystemRandom().uniform( + self.settings.data.download_delay_sec_min, self.settings.data.download_delay_sec_max + ), + 1, + ) + + self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.") + time.sleep(time_sleep) + + def media_move_and_symlink( + self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str + ) -> pathlib.Path: + """Move a media file and create a symlink if required. + + Args: + media (Track | Video): Media item. + path_media_src (pathlib.Path): Source file path. + file_extension (str): File extension. + + Returns: + pathlib.Path: Destination path. + """ + # Compute tracks path, sanitize and ensure path exists + file_name_relative: str = format_path_media( + self.settings.data.format_track, + media, + delimiter_artist=self.settings.data.filename_delimiter_artist, + delimiter_album_artist=self.settings.data.filename_delimiter_album_artist, + use_primary_album_artist=self.settings.data.use_primary_album_artist, + ) + path_media_dst: pathlib.Path = ( + pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension) + ).absolute() + path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True)) + + os.makedirs(path_media_dst.parent, exist_ok=True) + + # Move item and symlink it + if path_media_dst != path_media_src: + if self.skip_existing: + skip_file: bool = check_file_exists(path_media_dst, extension_ignore=False) + skip_symlink: bool = path_media_src.is_symlink() + else: + skip_file: bool = False + skip_symlink: bool = False + + if not skip_file: + self.fn_logger.debug(f"Move: {path_media_src} -> {path_media_dst}") + shutil.move(path_media_src, path_media_dst) + + if not skip_symlink: + self.fn_logger.debug(f"Symlink: {path_media_src} -> {path_media_dst}") + path_media_dst_relative: pathlib.Path = path_media_dst.relative_to(path_media_src.parent, walk_up=True) + + path_media_src.unlink(missing_ok=True) + path_media_src.symlink_to(path_media_dst_relative) + + return path_media_dst + + def adjust_quality_audio(self, quality: Quality) -> Quality: + """Temporarily set audio quality and return the previous value. + + Args: + quality (Quality): New audio quality. + + Returns: + Quality: Previous audio quality. + """ + # Save original quality settings + quality_old: Quality = self.session.audio_quality + self.session.audio_quality = quality + + return quality_old + + def adjust_quality_video(self, quality: QualityVideo) -> QualityVideo: + """Temporarily set video quality and return the previous value. + + Args: + quality (QualityVideo): New video quality. + + Returns: + QualityVideo: Previous video quality. + """ + quality_old: QualityVideo = self.settings.data.quality_video + + self.settings.data.quality_video = quality + + return quality_old + + def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool: + """Move a file from source to destination. + + Args: + path_file_source (pathlib.Path): Source file path. + path_file_destination (str | pathlib.Path): Destination file path. + + Returns: + bool: True if moved, False otherwise. + """ + result: bool + + # Check if the file was downloaded + if path_file_source and path_file_source.is_file(): + # Move it. + shutil.move(path_file_source, path_file_destination) + + result = True + else: + result = False + + return result + + def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + """Move a lyrics file to the destination. + + Args: + path_lyrics (pathlib.Path): Source lyrics file. + file_media_dst (pathlib.Path): Destination media file path. + + Returns: + bool: True if moved, False otherwise. + """ + # Build tmp lyrics filename + path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS) + result: bool = self._move_file(path_lyrics, path_file_lyrics) + + return result + + def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool: + """Move a cover file to the destination. + + Args: + path_cover (pathlib.Path): Source cover file. + file_media_dst (pathlib.Path): Destination media file path. + + Returns: + bool: True if moved, False otherwise. + """ + # Build tmp lyrics filename + path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME + result: bool = self._move_file(path_cover, path_file_cover) + + return result + + def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str: + """Write lyrics to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + lyrics (str): Lyrics content. + + Returns: + str: Path to the temp file. + """ + return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics) + + def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str: + """Write cover image to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + image (bytes): Image data. + + Returns: + str: Path to the temp file. + """ + return self.write_to_tmp_file(dir_destination, mode="xb", content=image) + + def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str: + """Write content to a temporary file. + + Args: + dir_destination (pathlib.Path): Directory for the temp file. + mode (str): File open mode. + content (str | bytes): Content to write. + + Returns: + str: Path to the temp file. + """ + result: pathlib.Path = dir_destination / str(uuid4()) + encoding: str | None = "utf-8" if isinstance(content, str) else None + + try: + with open(result, mode=mode, encoding=encoding) as f: + f.write(content) + except OSError: + result = "" + + return result + + @staticmethod + def cover_data(url: str | None = None, path_file: str | None = None) -> str | bytes: + """Retrieve cover image data from a URL or file. + + Args: + url (str | None, optional): URL to download image from. Defaults to None. + path_file (str | None, optional): Path to image file. Defaults to None. + + Returns: + str | bytes: Image data or empty string on failure. + """ + result: str | bytes = "" + + if url: + response = None + try: + response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC) + response.raise_for_status() + result = response.content + except requests.RequestException: + # Silently handle download errors (static method has no logger access) + pass + finally: + if response: + response.close() + elif path_file: + try: + with open(path_file, "rb") as f: + result = f.read() + except OSError: + # Silently handle file read errors (static method has no logger access) + pass + + return result + + def metadata_write( + self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream + ) -> tuple[bool, pathlib.Path | None, pathlib.Path | None]: + """Write metadata, lyrics, and cover to a media file. + + Args: + track (Track): Track object. + path_media (pathlib.Path): Path to media file. + is_parent_album (bool): Whether this is a parent album. + media_stream (Stream): Stream object. + + Returns: + tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover) + """ + result: bool = False + 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 "" + isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else "" + lyrics: str = "" + lyrics_synced: str = "" + lyrics_unsynced: str = "" + cover_data: bytes = None + + if self.settings.data.lyrics_embed or self.settings.data.lyrics_file: + # Try to retrieve lyrics. + try: + lyrics_obj = track.lyrics() + + if lyrics_obj.text: + lyrics_unsynced = lyrics_obj.text + lyrics = lyrics_unsynced + if lyrics_obj.subtitles: + lyrics_synced = lyrics_obj.subtitles + lyrics = lyrics_synced + except Exception: + lyrics = "" + self.fn_logger.debug(f"Could not retrieve lyrics for `{name_builder_item(track)}`.") + + if lyrics and self.settings.data.lyrics_file: + path_lyrics = self.lyrics_to_file(path_media.parent, lyrics) + + cover_dimension = self.settings.data.metadata_cover_dimension + + if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album): + # Do not write CoverDimensions.PxORIGIN to metadata, since it can exceed max metadata file size (>16Mb) + 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_dimension == CoverDimensions.PxORIGIN: + url_cover_album_file = track.album.image(CoverDimensions.PxORIGIN) + cover_data_album_file = self.cover_data(url=url_cover_album_file) + else: + cover_data_album_file = cover_data + + path_cover = self.cover_to_file(path_media.parent, cover_data_album_file) + + metadata_target_upc = MetadataTargetUPC(self.settings.data.metadata_target_upc) + target_upc: dict[str, str] = METADATA_LOOKUP_UPC[metadata_target_upc] + explicit: bool = track.explicit if hasattr(track, "explicit") else False + title = name_builder_title(track) + title += METADATA_EXPLICIT if explicit and self.settings.data.mark_explicit else "" + + # `None` values are not allowed. + m: Metadata = Metadata( + path_file=path_media, + target_upc=target_upc, + lyrics=lyrics_synced, + lyrics_unsynced=lyrics_unsynced, + copy_right=copy_right, + title=title, + artists=name_builder_artist(track, delimiter=self.settings.data.metadata_delimiter_artist), + album=track.album.name if track.album else "", + tracknumber=track.track_num, + date=release_date, + isrc=isrc, + albumartist=name_builder_album_artist(track, delimiter=self.settings.data.metadata_delimiter_album_artist), + totaltrack=track.album.num_tracks if track.album and track.album.num_tracks else 1, + totaldisc=track.album.num_volumes if track.album and track.album.num_volumes else 1, + discnumber=track.volume_num if track.volume_num else 1, + cover_data=cover_data if self.settings.data.metadata_cover_embed else None, + album_replay_gain=media_stream.album_replay_gain, + album_peak_amplitude=media_stream.album_peak_amplitude, + track_replay_gain=media_stream.track_replay_gain, + track_peak_amplitude=media_stream.track_peak_amplitude, + url_share=track.share_url if track.share_url and self.settings.data.metadata_write_url else "", + replay_gain_write=self.settings.data.metadata_replay_gain, + upc=track.album.upc if track.album and track.album.upc else "", + explicit=explicit, + ) + + m.save() + + result = True + + return result, path_lyrics, path_cover + + def items( + self, + file_template: str, + media: Album | Playlist | UserPlaylist | Mix | None = None, + media_id: str | None = None, + media_type: MediaType | None = None, + video_download: bool = False, + download_delay: bool = True, + quality_audio: Quality | None = None, + quality_video: QualityVideo | None = None, + ) -> None: + """Download all items in an album, playlist, or mix. + + Args: + file_template (str): Template for file naming. + media (Album | Playlist | UserPlaylist | Mix | None, optional): Media item. Defaults to None. + media_id (str | None, optional): Media ID. Defaults to None. + media_type (MediaType | None, optional): Media type. Defaults to None. + video_download (bool, optional): Whether to allow video downloads. Defaults to False. + download_delay (bool, optional): Whether to delay between downloads. Defaults to True. + quality_audio (Quality | None, optional): Audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Video quality. Defaults to None. + """ + # Validate and prepare media collection + validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download) + if validated_media is None or not isinstance(validated_media, Album | Playlist | UserPlaylist | Mix): + return + + media = validated_media + + # Set up download context + download_context = self._setup_collection_download_context(media, file_template, video_download) + file_name_relative, list_media_name, list_media_name_short, items, progress_stdout = download_context + + # Set up progress tracking + progress: Progress = self.progress_overall if self.progress_overall else self.progress + progress_task: TaskID = progress.add_task( + f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout + ) + + # Download configuration + is_album: bool = isinstance(media, Album) + sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative) + list_total: int = len(items) + + # Execute downloads + result_dirs: list[pathlib.Path] = self._execute_collection_downloads( + items, + file_name_relative, + quality_audio, + quality_video, + download_delay, + is_album, + list_total, + progress, + progress_task, + progress_stdout, + ) + + # Create playlist file if requested + if self.settings.data.playlist_create: + self.playlist_populate(set(result_dirs), list_media_name, is_album, sort_by_track_num) + + self.fn_logger.info(f"Finished list '{list_media_name}'.") + + def _setup_collection_download_context( + self, + media: Album | Playlist | UserPlaylist | Mix, + file_template: str, + video_download: bool, + ) -> tuple[str, str, str, list, bool]: + """Set up download context for media collection. + + Args: + media (Album | Playlist | UserPlaylist | Mix): Media collection. + file_template (str): Template for file naming. + video_download (bool): Whether to allow video downloads. + + Returns: + tuple[str, str, str, list, bool]: (file_name_relative, list_media_name, list_media_name_short, items, progress_stdout) + """ + # Create file name and path + file_name_relative: str = format_path_media( + file_template, + media, + delimiter_artist=self.settings.data.filename_delimiter_artist, + delimiter_album_artist=self.settings.data.filename_delimiter_album_artist, + use_primary_album_artist=self.settings.data.use_primary_album_artist, + ) + + # Get the name of the list and check, if videos should be included. + list_media_name: str = name_builder_title(media) + list_media_name_short: str = list_media_name[:30] + + # Get all items of the list. + items = items_results_all(media, videos_include=video_download) + + # Determine where to redirect the progress information. + if self.progress_gui is None: + progress_stdout: bool = True + else: + progress_stdout: bool = False + + self.progress_gui.list_name.emit(list_media_name_short) + + return file_name_relative, list_media_name, list_media_name_short, items, progress_stdout + + def _execute_collection_downloads( + self, + items: list, + file_name_relative: str, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + download_delay: bool, + is_album: bool, + list_total: int, + progress: Progress, + progress_task: TaskID, + progress_stdout: bool, + ) -> list[pathlib.Path]: + """Execute downloads for all items in the collection. + + Args: + items (list): List of media items to download. + file_name_relative (str): Relative file name template. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + download_delay (bool): Whether to apply download delay. + is_album (bool): Whether this is an album. + list_total (int): Total number of items. + progress (Progress): Progress bar instance. + progress_task (TaskID): Progress task ID. + progress_stdout (bool): Whether to show progress in stdout. + + Returns: + list[pathlib.Path]: List of result directories. + """ + result_dirs: list[pathlib.Path] = [] + + # Check if items list is empty + if not items: + # Mark progress as complete for empty lists + progress.update(progress_task, completed=progress.tasks[progress_task].total) + + if not progress_stdout and self.progress_gui: + self.progress_gui.list_item.emit(100.0) + + return result_dirs + + # Iterate through list items + while not progress.finished: + with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: + # Dispatch all download tasks to worker threads + download_futures: list[futures.Future] = [ + executor.submit( + self.item, + media=item_media, + file_template=file_name_relative, + quality_audio=quality_audio, + quality_video=quality_video, + download_delay=download_delay, + is_parent_album=is_album, + list_position=count + 1, + list_total=list_total, + ) + for count, item_media in enumerate(items) + ] + + # Process download results + result_dirs = self._process_download_futures(download_futures, progress, progress_task, progress_stdout) + + # Check for abort signal + if self.event_abort.is_set(): + return result_dirs + + return result_dirs + + def _create_download_futures( + self, + items: list, + file_name_relative: str, + quality_audio: Quality | None, + quality_video: QualityVideo | None, + download_delay: bool, + is_album: bool, + list_total: int, + ) -> list[futures.Future]: + """Create download futures for all items in the collection. + + Args: + items (list): List of media items to download. + file_name_relative (str): Relative file name template. + quality_audio (Quality | None): Audio quality setting. + quality_video (QualityVideo | None): Video quality setting. + download_delay (bool): Whether to apply download delay. + is_album (bool): Whether this is an album. + list_total (int): Total number of items. + + Returns: + list[futures.Future]: List of download futures. + """ + with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor: + return [ + executor.submit( + self.item, + media=item_media, + file_template=file_name_relative, + quality_audio=quality_audio, + quality_video=quality_video, + download_delay=download_delay, + is_parent_album=is_album, + list_position=count + 1, + list_total=list_total, + ) + for count, item_media in enumerate(items) + ] + + def _process_download_futures( + self, + futures_list: list[futures.Future], + progress: Progress, + progress_task: TaskID, + progress_stdout: bool, + ) -> list[pathlib.Path]: + """Process download futures and collect results. + + Args: + futures_list (list[futures.Future]): List of download futures. + progress (Progress): Progress bar instance. + progress_task (TaskID): Progress task ID. + progress_stdout (bool): Whether to show progress in stdout. + + Returns: + list[pathlib.Path]: List of result directories. + """ + result_dirs: list[pathlib.Path] = [] + + # Report results as they become available + for future in futures.as_completed(futures_list): + # Retrieve result + status, result_path_file = future.result() + + if result_path_file: + result_dirs.append(result_path_file.parent) + + # Advance progress bar. + progress.advance(progress_task) + + if not progress_stdout: + self.progress_gui.list_item.emit(progress.tasks[progress_task].percentage) + + # If app is terminated (CTRL+C) + if self.event_abort.is_set(): + # Cancel all not yet started tasks + for f in futures_list: + f.cancel() + + break + + return result_dirs + + def playlist_populate( + self, dirs_scoped: set[pathlib.Path], name_list: str, is_album: bool, sort_alphabetically: bool + ) -> list[pathlib.Path]: + """Create playlist files (m3u) for downloaded tracks in each directory. + + Args: + dirs_scoped (set[pathlib.Path]): Set of directories containing tracks. + name_list (str): Name of the playlist. + is_album (bool): Whether this is an album. + sort_alphabetically (bool): Whether to sort tracks alphabetically. + + Returns: + list[pathlib.Path]: List of created playlist file paths. + """ + result: list[pathlib.Path] = [] + + # For each dir, which contains tracks + for dir_scoped in dirs_scoped: + # Sanitize final playlist name to fit into OS boundaries. + path_playlist = dir_scoped / sanitize_filename(PLAYLIST_PREFIX + name_list + PLAYLIST_EXTENSION) + path_playlist = pathlib.Path(path_file_sanitize(path_playlist, adapt=True)) + + self.fn_logger.debug(f"Playlist: Creating {path_playlist}") + + # Get all tracks in the directory + path_tracks: list[pathlib.Path] = [] + + for extension_audio in AudioExtensionsValid: + path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}")) + + # Sort alphabetically, e.g. if items are prefixed with numbers + if sort_alphabetically: + path_tracks.sort() + elif not is_album: + # If it is not an album sort by creation time + path_tracks.sort( + key=lambda x: x.stat().st_birthtime if hasattr(x.stat(), "st_birthtime") else x.stat().st_ctime + ) + + # Write data to m3u file + with path_playlist.open(mode="w", encoding="utf-8") as f: + for path_track in path_tracks: + # If it's a symlink write the relative file path to the actual track into the playlist file + if path_track.is_symlink(): + media_file_target = path_track.resolve().relative_to(path_track.parent, walk_up=True) + else: + media_file_target = path_track.name + + f.write(str(media_file_target) + os.linesep) + + result.append(path_playlist) + + return result + + def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path: + """Convert a TS video file to MP4 using ffmpeg. + + Args: + path_file (pathlib.Path): Path to the TS file. + + Returns: + pathlib.Path: Path to the converted MP4 file. + """ + path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) + + self.fn_logger.debug(f"Converting video: {path_file.name} -> {path_file_out.name}") + + ffmpeg = ( + FFmpeg(executable=self.settings.data.path_binary_ffmpeg) + .option("y") + .option("hide_banner") + .option("nostdin") + .input(url=path_file) + .output(url=path_file_out, codec="copy", map=0, loglevel="quiet") + ) + + ffmpeg.execute() + + self.fn_logger.debug(f"Video conversion complete: {path_file_out.name}") + + return path_file_out + + def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path: + """Extract FLAC audio from a media file using ffmpeg. + + Args: + path_media_src (pathlib.Path): Path to the source media file. + + Returns: + pathlib.Path: Path to the extracted FLAC file. + """ + path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC) + + self.fn_logger.debug(f"Extracting FLAC: {path_media_src.name} -> {path_media_out.name}") + + ffmpeg = ( + FFmpeg(executable=self.settings.data.path_binary_ffmpeg) + .option("hide_banner") + .option("nostdin") + .input(url=path_media_src) + .output( + url=path_media_out, + map=0, + movflags="use_metadata_tags", + acodec="copy", + map_metadata="0:g", + loglevel="quiet", + ) + ) + + ffmpeg.execute() + + self.fn_logger.debug(f"FLAC extraction complete: {path_media_out.name}") + + return path_media_out + + def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> tuple[m3u8.M3U8 | bool, str]: + """Extract the best matching video stream from an m3u8 variant playlist. + + Args: + m3u8_variant (m3u8.M3U8): The m3u8 variant playlist. + quality (int): Desired video quality (vertical resolution). + + Returns: + tuple[m3u8.M3U8 | bool, str]: (Selected m3u8 playlist or False, codecs string) + """ + m3u8_playlist: m3u8.M3U8 | bool = False + resolution_best: int = 0 + mime_type: str = "" + + if m3u8_variant.is_variant: + for playlist in m3u8_variant.playlists: + if resolution_best < playlist.stream_info.resolution[1]: + resolution_best = playlist.stream_info.resolution[1] + m3u8_playlist = m3u8.load(playlist.uri) + mime_type = playlist.stream_info.codecs + + if quality == playlist.stream_info.resolution[1]: + break + + return m3u8_playlist, mime_type diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py new file mode 100644 index 0000000..74e562a --- /dev/null +++ b/tidal_dl_ng/gui.py @@ -0,0 +1,2521 @@ +# Compilation mode, support OS-specific options +# nuitka-project-if: {OS} in ("Darwin"): +# nuitka-project: --macos-create-app-bundle +# nuitka-project: --macos-app-icon=tidal_dl_ng/ui/icon.icns +# nuitka-project: --macos-signed-app-name=com.exislow.TidalDlNg +# nuitka-project: --macos-app-mode=gui +# nuitka-project-if: {OS} in ("Linux", "FreeBSD"): +# nuitka-project: --linux-icon=tidal_dl_ng/ui/icon512.png +# nuitka-project-if: {OS} in ("Windows"): +# nuitka-project: --windows-icon-from-ico=tidal_dl_ng/ui/icon.ico +# nuitka-project: --file-description="TIDAL media downloader next generation." + +# Debugging options, controlled via environment variable at compile time. +# nuitka-project-if: {OS} == "Windows" and os.getenv("DEBUG_COMPILATION", "no") == "yes": +# nuitka-project: --windows-console-mode=hide +# nuitka-project-else: +# nuitka-project: --windows-console-mode=disable +# nuitka-project-if: os.getenv("DEBUG_COMPILATION", "no") == "yes": +# nuitka-project: --debug +# nuitka-project: --debugger +# nuitka-project: --experimental=allow-c-warnings +# nuitka-project: --no-debug-immortal-assumptions +# nuitka-project: --run +# nuitka-project-else: +# nuitka-project: --assume-yes-for-downloads +# nuitka-project-if: os.getenv("DEPLOYMENT", "no") == "yes": +# nuitka-project: --deployment + +# The PySide6 plugin covers qt-plugins +# nuitka-project: --standalone +# nuitka-project: --output-dir=dist +# nuitka-project: --enable-plugin=pyside6 +# nuitka-project: --include-qt-plugins=qml +# nuitka-project: --noinclude-dlls=libQt6Charts* +# nuitka-project: --noinclude-dlls=libQt6Quick3D* +# nuitka-project: --noinclude-dlls=libQt6Sensors* +# nuitka-project: --noinclude-dlls=libQt6Test* +# nuitka-project: --noinclude-dlls=libQt6WebEngine* +# nuitka-project: --include-data-files={MAIN_DIRECTORY}/ui/icon*=tidal_dl_ng/ui/ +# nuitka-project: --include-data-files={MAIN_DIRECTORY}/ui/default_album_image.png=tidal_dl_ng/ui/default_album_image.png +# nuitka-project: --include-data-files=./pyproject.toml=pyproject.toml +# nuitka-project: --force-stderr-spec="{TEMP}/tidal-dl-ng.err.log" +# nuitka-project: --force-stdout-spec="{TEMP}/tidal-dl-ng.out.log" +# nuitka-project: --company-name=exislow + + +import math +import sys +import time +from collections.abc import Callable, Iterable, Sequence +from typing import Any + +from requests.exceptions import HTTPError +from tidalapi.session import LinkLogin + +from tidal_dl_ng import __version__, update_available +from tidal_dl_ng.dialog import DialogLogin, DialogPreferences, DialogVersion +from tidal_dl_ng.helper.gui import ( + FilterHeader, + HumanProxyModel, + get_queue_download_media, + get_queue_download_quality_audio, + get_queue_download_quality_video, + get_results_media_item, + get_user_list_media_item, + set_queue_download_media, + set_user_list_media, +) +from tidal_dl_ng.helper.path import get_format_template, resource_path +from tidal_dl_ng.helper.tidal import ( + favorite_function_factory, + get_tidal_media_id, + get_tidal_media_type, + instantiate_media, + items_results_all, + name_builder_artist, + name_builder_title, + quality_audio_highest, + search_results_all, + url_ending_clean, + user_media_lists, +) + +try: + import qdarktheme + from PySide6 import QtCore, QtGui, QtWidgets +except ImportError as e: + print(e) + print("Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully.") + sys.exit(1) + + +from ansi2html import Ansi2HTMLConverter +from rich.progress import Progress +from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video +from tidalapi.artist import Artist +from tidalapi.media import AudioMode +from tidalapi.playlist import Folder +from tidalapi.session import SearchTypes + +from tidal_dl_ng.config import HandlingApp, Settings, Tidal +from tidal_dl_ng.constants import FAVORITES, QualityVideo, QueueDownloadStatus, TidalLists +from tidal_dl_ng.download import Download +from tidal_dl_ng.logger import XStream, logger_gui +from tidal_dl_ng.model.gui_data import ProgressBars, QueueDownloadItem, ResultItem, StatusbarMessage +from tidal_dl_ng.model.meta import ReleaseLatest +from tidal_dl_ng.ui.main import Ui_MainWindow +from tidal_dl_ng.ui.spinner import QtWaitingSpinner +from tidal_dl_ng.worker import Worker + + +# TODO: Make more use of Exceptions +class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): + """Main application window for TIDAL Downloader Next Generation. + + Handles GUI setup, user interactions, and download logic. + """ + + settings: Settings + tidal: Tidal + dl: Download + threadpool: QtCore.QThreadPool + tray: QtWidgets.QSystemTrayIcon + spinners: dict + cover_url_current: str = "" + shutdown: bool = False + model_tr_results: QtGui.QStandardItemModel = QtGui.QStandardItemModel() + proxy_tr_results: HumanProxyModel + s_spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget) + s_spinner_stop: QtCore.Signal = QtCore.Signal() + pb_item: QtWidgets.QProgressBar + s_item_advance: QtCore.Signal = QtCore.Signal(float) + s_item_name: QtCore.Signal = QtCore.Signal(str) + s_list_name: QtCore.Signal = QtCore.Signal(str) + pb_list: QtWidgets.QProgressBar + s_list_advance: QtCore.Signal = QtCore.Signal(float) + s_pb_reset: QtCore.Signal = QtCore.Signal() + s_populate_tree_lists: QtCore.Signal = QtCore.Signal(dict) + s_populate_folder_children: QtCore.Signal = QtCore.Signal(object, list, list) + s_statusbar_message: QtCore.Signal = QtCore.Signal(object) + s_tr_results_add_top_level_item: QtCore.Signal = QtCore.Signal(object) + s_settings_save: QtCore.Signal = QtCore.Signal() + s_pb_reload_status: QtCore.Signal = QtCore.Signal(bool) + s_update_check: QtCore.Signal = QtCore.Signal(bool) + s_update_show: QtCore.Signal = QtCore.Signal(bool, bool, object) + s_queue_download_item_downloading: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_finished: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_failed: QtCore.Signal = QtCore.Signal(object) + s_queue_download_item_skipped: QtCore.Signal = QtCore.Signal(object) + converter_ansi_html: Ansi2HTMLConverter + dialog_preferences: DialogPreferences | None = None + + def __init__(self, tidal: Tidal | None = None) -> None: + """Initialize the main window and all components. + + Args: + tidal (Tidal | None): Optional Tidal session object. + """ + super().__init__() + self.setupUi(self) + self.setWindowTitle("TIDAL Downloader Next Generation!") + + # Logging redirect. + XStream.stdout().messageWritten.connect(self._log_output) + # XStream.stderr().messageWritten.connect(self._log_output) + + self.settings = Settings() + + self._init_threads() + self._init_gui() + self._init_tree_results_model(self.model_tr_results) + self._init_tree_results(self.tr_results, self.model_tr_results) + self._init_tree_lists(self.tr_lists_user) + self._init_tree_queue(self.tr_queue_download) + self._init_info() + self._init_progressbar() + self._populate_quality(self.cb_quality_audio, Quality) + self._populate_quality(self.cb_quality_video, QualityVideo) + self._populate_search_types(self.cb_search_type, SearchTypes) + self.apply_settings(self.settings) + self._init_signals() + self._init_buttons() + self.init_tidal(tidal) + + logger_gui.debug("All setup.") + + def _init_gui(self) -> None: + """Initialize GUI-specific variables and state.""" + self.setGeometry( + self.settings.data.window_x, + self.settings.data.window_y, + self.settings.data.window_w, + self.settings.data.window_h, + ) + self.spinners: dict[QtWidgets.QWidget, QtWaitingSpinner] = {} + self.converter_ansi_html: Ansi2HTMLConverter = Ansi2HTMLConverter() + + def init_tidal(self, tidal: Tidal | None = None): + """Initialize Tidal session and handle login flow. + + Args: + tidal (Tidal, optional): Existing Tidal session. Defaults to None. + """ + result: bool = False + + if tidal: + self.tidal = tidal + result = True + else: + self.tidal = Tidal(self.settings) + result = self.tidal.login_token() + + if not result: + hint: str = "After you have finished the TIDAL login via web browser click the 'OK' button." + + while not result: + link_login: LinkLogin = self.tidal.session.get_link_login() + expires_in = int(link_login.expires_in) if hasattr(link_login, "expires_in") else 0 + d_login: DialogLogin = DialogLogin( + url_login=link_login.verification_uri_complete, + hint=hint, + expires_in=expires_in, + parent=self, + ) + + if d_login.return_code == 1: + try: + self.tidal.session.process_link_login(link_login, until_expiry=False) + self.tidal.login_finalize() + + result = True + logger_gui.info("Login successful. Have fun!") + except (HTTPError, Exception): + hint = "Something was wrong with your redirect url. Please try again!" + logger_gui.warning("Login not successful. Try again...") + else: + # If user has pressed cancel. + sys.exit(1) + + if result: + self._init_dl() + self.thread_it(self.tidal_user_lists) + + def _init_threads(self): + """Initialize thread pool and start background workers.""" + self.threadpool = QtCore.QThreadPool() + self.thread_it(self.watcher_queue_download) + + def _init_dl(self): + """Initialize Download object and related progress bars.""" + # Init `Download` object. + data_pb: ProgressBars = ProgressBars( + item=self.s_item_advance, + list_item=self.s_list_advance, + item_name=self.s_item_name, + list_name=self.s_list_name, + ) + progress: Progress = Progress() + handling_app: HandlingApp = HandlingApp() + self.dl = Download( + tidal_obj=self.tidal, + skip_existing=self.tidal.settings.data.skip_existing, + path_base=self.settings.data.download_base_path, + fn_logger=logger_gui, + progress_gui=data_pb, + progress=progress, + event_abort=handling_app.event_abort, + event_run=handling_app.event_run, + ) + + def _init_progressbar(self): + """Initialize and add progress bars to the status bar.""" + self.pb_list = QtWidgets.QProgressBar() + self.pb_item = QtWidgets.QProgressBar() + pbs = [self.pb_list, self.pb_item] + + for pb in pbs: + pb.setRange(0, 100) + # self.pb_progress.setVisible() + self.statusbar.addPermanentWidget(pb) + + def _init_info(self): + """Set default album cover image in the GUI.""" + path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") + + self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) + + def on_progress_reset(self): + """Reset progress bars to zero.""" + self.pb_list.setValue(0) + self.pb_item.setValue(0) + + def on_statusbar_message(self, data: StatusbarMessage): + """Show a message in the status bar. + + Args: + data (StatusbarMessage): Message and timeout. + """ + self.statusbar.showMessage(data.message, data.timeout) + + def _log_output(self, text: str) -> None: + """Redirect log output to the debug text area. + + Args: + text (str): Log message. + """ + cursor: QtGui.QTextCursor = self.te_debug.textCursor() + html = self.converter_ansi_html.convert(text) + + cursor.movePosition(QtGui.QTextCursor.End) + cursor.insertHtml(html) + self.te_debug.setTextCursor(cursor) + self.te_debug.ensureCursorVisible() + + def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: + """Populate a combo box with quality options. + + Args: + ui_target (QComboBox): Target combo box. + options (Iterable): Enum of quality options. + """ + for item in options: + ui_target.addItem(item.name, item) + + def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: + """Populate a combo box with search type options. + + Args: + ui_target (QComboBox): Target combo box. + options (Iterable): Enum of search types. + """ + for item in options: + if item: + ui_target.addItem(item.__name__, item) + + self.cb_search_type.setCurrentIndex(2) + + def handle_filter_activated(self) -> None: + """Handle activation of filter headers in the results tree.""" + header = self.tr_results.header() + filters: list[tuple[int, str]] = [] + for i in range(header.count()): + text: str = header.filter_text(i) + + if text: + filters.append((i, text)) + + proxy_model: HumanProxyModel = self.tr_results.model() + proxy_model.filters = filters + + def _init_tree_results(self, tree: QtWidgets.QTreeView, model: QtGui.QStandardItemModel) -> None: + """Initialize the results tree view and its model. + + Args: + tree (QTreeView): The tree view widget. + model (QStandardItemModel): The model for the tree. + """ + header: FilterHeader = FilterHeader(tree) + self.proxy_tr_results: HumanProxyModel = HumanProxyModel(self) + + tree.setHeader(header) + tree.setModel(model) + self.proxy_tr_results.setSourceModel(model) + tree.setModel(self.proxy_tr_results) + header.set_filter_boxes(model.columnCount()) + header.filter_activated.connect(self.handle_filter_activated) + ## Styling + tree.sortByColumn(0, QtCore.Qt.SortOrder.AscendingOrder) + tree.setColumnHidden(1, True) + normal_width = max(150, (self.width() * 0.13)) # 12% for normal fields + narrow_width = max(90, (self.width() * 0.06)) # 6% for shorter fields + skinny_width = max(60, (self.width() * 0.03)) # 3% for very short fields + tree.setColumnWidth(2, normal_width) # artist + tree.setColumnWidth(3, normal_width) # title + tree.setColumnWidth(4, normal_width) # album + tree.setColumnWidth(5, skinny_width) # duration + tree.setColumnWidth(6, narrow_width) # quality + tree.setColumnWidth(7, narrow_width) # date + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_results) + + def _init_tree_results_model(self, model: QtGui.QStandardItemModel) -> None: + """Initialize the model for the results tree view. + + Args: + model (QStandardItemModel): The model to initialize. + """ + labels_column: list[str] = ["#", "obj", "Artist", "Title", "Album", "Duration", "Quality", "Date"] + + model.setColumnCount(len(labels_column)) + model.setRowCount(0) + model.setHorizontalHeaderLabels(labels_column) + + def _init_tree_queue(self, tree: QtWidgets.QTableWidget) -> None: + """Initialize the download queue table widget. + + Args: + tree (QTableWidget): The 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) + tree.customContextMenuRequested.connect(self.menu_context_queue_download) + + def tidal_user_lists(self) -> None: + """Fetch and emit user playlists, mixes, and favorites from Tidal.""" + # Start loading spinner + self.s_spinner_start.emit(self.tr_lists_user) + self.s_pb_reload_status.emit(False) + + user_all: dict[str, list] = user_media_lists(self.tidal.session) + + self.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. + + Args: + user_lists (dict[str, list]): Dictionary with 'playlists' (Folder/Playlist) and 'mixes' lists. + """ + twi_playlists: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Playlists, QtCore.Qt.MatchExactly, 0 + )[0] + twi_mixes: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Mixes, QtCore.Qt.MatchExactly, 0 + )[0] + twi_favorites: QtWidgets.QTreeWidgetItem = self.tr_lists_user.findItems( + TidalLists.Favorites, QtCore.Qt.MatchExactly, 0 + )[0] + + # Remove all children if present + for twi in [twi_playlists, twi_mixes]: + for i in reversed(range(twi.childCount())): + twi.removeChild(twi.child(i)) + + # Populate playlists (including folders) + for item in user_lists.get("playlists", []): + if isinstance(item, Folder): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name: str = f"📁 {item.name}" + info: str = f"({item.total_number_of_items} items)" if item.total_number_of_items else "" + twi_child.setText(0, name) + set_user_list_media(twi_child, item) + twi_child.setText(2, info) + + # Add disabled dummy child to show expansion arrow + dummy_child = QtWidgets.QTreeWidgetItem(twi_child) + dummy_child.setDisabled(True) + elif isinstance(item, UserPlaylist | Playlist): + twi_child = QtWidgets.QTreeWidgetItem(twi_playlists) + name: str = item.name if getattr(item, "name", None) is not None else "" + description: str = f" {item.description}" if item.description else "" + info: str = 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) + + # Populate mixes + for item in user_lists.get("mixes", []): + if isinstance(item, Mix): + twi_child = QtWidgets.QTreeWidgetItem(twi_mixes) + name: str = item.title + info: str = item.sub_title + twi_child.setText(0, name) + set_user_list_media(twi_child, item) + twi_child.setText(2, info) + + # Remove all children from favorites to avoid duplication + for i in reversed(range(twi_favorites.childCount())): + twi_favorites.removeChild(twi_favorites.child(i)) + + # Populate static favorites + for key, favorite in FAVORITES.items(): + twi_child = QtWidgets.QTreeWidgetItem(twi_favorites) + name: str = favorite["name"] + info: str = "" + + twi_child.setText(0, name) + set_user_list_media(twi_child, key) + twi_child.setText(2, info) + + # Stop load spinner + self.s_spinner_stop.emit() + self.s_pb_reload_status.emit(True) + + def _init_tree_lists(self, tree: QtWidgets.QTreeWidget) -> None: + """Initialize the user lists tree widget. + + Args: + tree (QTreeWidget): The tree widget. + """ + # Adjust Tree. + tree.setColumnWidth(0, 200) + tree.setColumnHidden(1, True) + tree.setColumnWidth(2, 300) + tree.expandAll() + + # Connect the contextmenu + tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + tree.customContextMenuRequested.connect(self.menu_context_tree_lists) + + def on_update_check(self, on_startup: bool = True) -> None: + """Check for application updates and emit update signals. + + Args: + on_startup (bool, optional): Whether this is called on startup. Defaults to True. + """ + is_available, info = update_available() + + if (on_startup and is_available) or not on_startup: + self.s_update_show.emit(True, is_available, info) + + def apply_settings(self, settings: Settings) -> None: + """Apply user settings to the GUI. + + Args: + settings (Settings): The settings object. + """ + quality_audio = getattr(getattr(settings, "data", None), "quality_audio", 1) + quality_video = getattr(getattr(settings, "data", None), "quality_video", 0) + elements = [ + {"element": self.cb_quality_audio, "setting": quality_audio, "default_id": 1}, + {"element": self.cb_quality_video, "setting": quality_video, "default_id": 0}, + ] + + for item in elements: + idx = item["element"].findData(item["setting"]) + + if idx > -1: + item["element"].setCurrentIndex(idx) + else: + item["element"].setCurrentIndex(item["default_id"]) + + def on_spinner_start(self, parent: QtWidgets.QWidget) -> None: + """Start a loading spinner on the given parent widget. + + Args: + parent (QWidget): The parent widget. + """ + # Stop any existing spinner for this parent + if parent in self.spinners: + spinner = self.spinners[parent] + + spinner.stop() + spinner.deleteLater() + del self.spinners[parent] + + # Create and start a new spinner for this parent + spinner = QtWaitingSpinner(parent, True, True) + + spinner.setColor(QtGui.QColor(255, 255, 255)) + spinner.start() + + self.spinners[parent] = spinner + + def on_spinner_stop(self) -> None: + """Stop all active loading spinners.""" + # Stop all spinners + for spinner in list(self.spinners.values()): + spinner.stop() + spinner.deleteLater() + + self.spinners.clear() + + def menu_context_tree_lists(self, point: QtCore.QPoint) -> None: + """Show context menu for user lists tree. + + Args: + point (QPoint): The point where the menu is requested. + """ + # Infos about the node selected. + index = self.tr_lists_user.indexAt(point) + + # Do not open menu if something went wrong or a parent node is clicked. + if not index.isValid() or not index.parent().data(): + return + + # Get the media item to determine type + item = self.tr_lists_user.itemAt(point) + media = get_user_list_media_item(item) + + # We build the menu. + menu = QtWidgets.QMenu() + + if isinstance(media, Folder): + # Folder-specific menu items + menu.addAction( + "Download All Playlists in Folder", lambda: self.thread_it(self.on_download_folder_playlists, point) + ) + menu.addAction( + "Download All Albums from Folder", lambda: self.thread_it(self.on_download_folder_albums, point) + ) + elif isinstance(media, str): + # Favorites items (stored as string keys like "fav_tracks", "fav_albums") + menu.addAction("Download All Items", lambda: self.thread_it(self.on_download_favorites, point)) + menu.addAction( + "Download All Albums from Items", lambda: self.thread_it(self.on_download_albums_from_favorites, point) + ) + else: + # Playlist/Mix menu items (existing) + menu.addAction("Download Playlist", lambda: self.thread_download_list_media(point)) + menu.addAction( + "Download All Albums in Playlist", + lambda: self.thread_it(self.on_download_all_albums_from_playlist, point), + ) + menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_lists_user, point)) + + menu.exec(self.tr_lists_user.mapToGlobal(point)) + + def menu_context_tree_results(self, point: QtCore.QPoint) -> None: + """Show context menu for results tree. + + Args: + point (QPoint): The point where the menu is requested. + """ + # Infos about the node selected. + index = self.tr_results.indexAt(point) + + # Do not open menu if something went wrong or a parent node is clicked. + if not index.isValid(): + return + + # Get the media item at this point + media = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results) + + # We build the menu. + menu = QtWidgets.QMenu() + + # Add "Download Full Album" option if it's a track or video with an album + if isinstance(media, Track | Video) and hasattr(media, "album") and media.album: + menu.addAction("Download Full Album", lambda: self.thread_download_album_from_track(point)) + + menu.addAction("Copy Share URL", lambda: self.on_copy_url_share(self.tr_results, point)) + + menu.exec(self.tr_results.mapToGlobal(point)) + + def menu_context_queue_download(self, point: QtCore.QPoint) -> None: + """Show context menu for download queue. + + Args: + point (QPoint): The point where the menu is requested. + """ + # Get the item at this point + item = self.tr_queue_download.itemAt(point) + + if not item: + return + + # Build the menu + menu = QtWidgets.QMenu() + + # Show remove option for waiting items + 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.tr_queue_download.mapToGlobal(point)) + + def on_queue_download_remove_item(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Remove a specific item from the download queue. + + Args: + item (QTreeWidgetItem): The item to remove. + """ + index = self.tr_queue_download.indexOfTopLevelItem(item) + if index >= 0: + self.tr_queue_download.takeTopLevelItem(index) + logger_gui.info("Removed item from download queue") + + def thread_download_list_media(self, point: QtCore.QPoint) -> None: + """Start download of a list media item in a thread. + + Args: + point (QPoint): The point in the tree. + """ + self.thread_it(self.on_download_list_media, point) + + def on_download_all_albums_from_playlist(self, point: QtCore.QPoint) -> None: + """Download all unique albums from tracks in a playlist. + + Args: + point (QPoint): The point in the tree where the playlist was right-clicked. + """ + try: + # Get and validate the playlist + item = self.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 + + # Get all items from the playlist + logger_gui.info(f"Fetching all tracks from: {media_list.name}") + media_items = items_results_all(media_list) + + # Extract unique album IDs from tracks + album_ids = self._extract_album_ids_from_tracks(media_items) + + if not album_ids: + logger_gui.warning("No albums found in this playlist.") + return + + logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...") + + # Load albums with rate limiting + albums_dict = self._load_albums_with_rate_limiting(album_ids) + + if not albums_dict: + logger_gui.error("Failed to load any albums from playlist.") + return + + # Prepare and queue albums + self._queue_loaded_albums(albums_dict) + + # Show confirmation + message = f"Added {len(albums_dict)} albums to download queue" + self.s_statusbar_message.emit(StatusbarMessage(message=message, timeout=3000)) + logger_gui.info(message) + + except Exception as e: + error_msg = f"Error downloading albums from playlist: {e!s}" + logger_gui.error(error_msg) + self.s_statusbar_message.emit(StatusbarMessage(message=error_msg, timeout=3000)) + + def _extract_album_ids_from_tracks(self, media_items: list) -> dict[int, Album]: + """Extract unique album IDs from a list of media items. + + Args: + media_items (list): List of media items (tracks/videos) from a playlist. + + Returns: + dict[int, Album]: Dictionary mapping album IDs to album stub objects. + """ + album_ids = {} + + for media_item in media_items: + if not isinstance(media_item, Track | Video): + continue + + if not hasattr(media_item, "album") or not media_item.album: + continue + + try: + # Access album.id carefully as it might trigger API calls + album_id = media_item.album.id + if album_id: + album_ids[album_id] = media_item.album + except Exception as e: + logger_gui.debug(f"Skipping track with unavailable album: {e!s}") + continue + + return album_ids + + def _load_albums_with_rate_limiting(self, album_ids: dict[int, Album]) -> dict[int, Album]: + """Load full album objects with rate limiting to prevent API throttling. + + Args: + album_ids (dict[int, Album]): Dictionary of album IDs to album stubs. + + Returns: + dict[int, Album]: Dictionary of successfully loaded full album objects. + """ + albums_dict = {} + batch_size = self.settings.data.api_rate_limit_batch_size + delay_sec = self.settings.data.api_rate_limit_delay_sec + + for idx, album_id in enumerate(album_ids.keys(), start=1): + try: + # Add delay every N albums to avoid rate limiting + if idx > 1 and (idx - 1) % batch_size == 0: + logger_gui.info(f"🛑 RATE LIMITING: Processed {idx - 1} albums, pausing for {delay_sec} seconds...") + time.sleep(delay_sec) + + # Check session validity before making API calls + if not self.tidal.session.check_login(): + logger_gui.error("Session expired. Please restart the application and login again.") + return albums_dict + + # Reload full album object + album = self.tidal.session.album(album_id) + albums_dict[album.id] = album + logger_gui.debug(f"Loaded album {idx}/{len(album_ids)}: {name_builder_artist(album)} - {album.name}") + + except Exception as e: + if not self._handle_album_load_error(e, album_id): + return albums_dict + continue + + logger_gui.info(f"Successfully loaded {len(albums_dict)} albums.") + return albums_dict + + def _handle_album_load_error(self, error: Exception, album_id: int) -> bool: + """Handle errors that occur when loading an album. + + Args: + error (Exception): The exception that was raised. + album_id (int): The ID of the album that failed to load. + + Returns: + bool: True if processing should continue, False if it should stop. + """ + # Check for OAuth/authentication errors using Tidal class method + if self.tidal.is_authentication_error(error): + error_msg = str(error) + logger_gui.error(f"Authentication error: {error_msg}") + logger_gui.error("Your session has expired. Please restart the application and login again.") + self.s_statusbar_message.emit( + StatusbarMessage(message="Session expired - please restart and login", timeout=5000) + ) + return False + + logger_gui.warning(f"Failed to load album {album_id}: {error!s}") + logger_gui.info( + "Note: Some albums may be unavailable due to region restrictions or removal from TIDAL. This is normal." + ) + return True + + def _queue_loaded_albums(self, albums_dict: dict[int, Album]) -> None: + """Prepare and add loaded albums to the download queue. + + Args: + albums_dict (dict[int, Album]): Dictionary of successfully loaded albums. + """ + logger_gui.info(f"Preparing queue items for {len(albums_dict)} albums...") + + queue_items = [] + for album in albums_dict.values(): + queue_dl_item = self.media_to_queue_download_model(album) + if queue_dl_item: + queue_items.append((queue_dl_item, album)) + logger_gui.debug(f"Prepared: {name_builder_artist(album)} - {album.name}") + + # Add all items to queue + logger_gui.info(f"Adding {len(queue_items)} albums to queue...") + for queue_dl_item, album in queue_items: + self.queue_download_media(queue_dl_item) + logger_gui.info(f"Added: {name_builder_artist(album)} - {album.name}") + + def on_copy_url_share( + self, tree_target: QtWidgets.QTreeWidget | QtWidgets.QTreeView, point: QtCore.QPoint = None + ) -> None: + """Copy the share URL of a media item to the clipboard. + + Args: + tree_target (QTreeWidget | QTreeView): The tree widget. + point (QPoint, optional): The point in the tree. Defaults to None. + """ + if isinstance(tree_target, QtWidgets.QTreeWidget): + + item: QtWidgets.QTreeWidgetItem = tree_target.itemAt(point) + media: Album | Artist | Mix | Playlist = get_user_list_media_item(item) + else: + index: QtCore.QModelIndex = tree_target.indexAt(point) + media: Track | Video | Album | Artist | Mix | Playlist = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + clipboard = QtWidgets.QApplication.clipboard() + url_share = media.share_url if hasattr(media, "share_url") else "No share URL available." + + clipboard.clear() + clipboard.setText(url_share) + + def on_download_list_media(self, point: QtCore.QPoint | None = None) -> None: + """Download all media items in a selected list. + + Args: + point (QPoint | None, optional): The point in the tree. Defaults to None. + """ + items: list[QtWidgets.QTreeWidgetItem] = [] + + if point: + items = [self.tr_lists_user.itemAt(point)] + else: + items = self.tr_lists_user.selectedItems() + + if len(items) == 0: + logger_gui.error("Please select a mix or playlist first.") + + for item in items: + media = get_user_list_media_item(item) + queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(media) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + + def on_download_folder_playlists(self, point: QtCore.QPoint) -> None: + """Download all playlists in a folder. + + Args: + point (QPoint): The point in the tree where the folder was right-clicked. + """ + try: + # Get and validate the folder + item = self.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 + + # Fetch all playlists in the folder + 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 + + # Queue each playlist for download + logger_gui.info(f"Queueing {len(playlists)} playlists from folder: {media.name}") + + for playlist in playlists: + queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(playlist) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + + logger_gui.info(f"✅ Successfully queued {len(playlists)} playlists from folder: {media.name}") + + except Exception as e: + logger_gui.exception(f"Error downloading playlists from folder: {e}") + logger_gui.error("Failed to download playlists from folder. See log for details.") + + def on_download_folder_albums(self, point: QtCore.QPoint) -> None: + """Download all unique albums from all playlists in a folder. + + Args: + point (QPoint): The point in the tree where the folder was right-clicked. + """ + try: + # Get and validate the folder + item = self.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 + + # Fetch all playlists in the folder + 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"Found {len(playlists)} playlists in folder: {media.name}") + + # Collect all tracks from all playlists + all_tracks: list[Track] = [] + + for playlist in playlists: + try: + tracks = self._get_playlist_tracks(playlist) + all_tracks.extend(tracks) + logger_gui.debug(f"Collected {len(tracks)} tracks from playlist: {playlist.name}") + except Exception as e: + logger_gui.error(f"Error getting tracks from playlist '{playlist.name}': {e}") + continue + + if not all_tracks: + logger_gui.info(f"No tracks found in folder playlists: {media.name}") + return + + logger_gui.info(f"Collected {len(all_tracks)} total tracks from all playlists") + + # Extract unique album IDs + album_ids = self._extract_album_ids_from_tracks(all_tracks) + logger_gui.info(f"Found {len(album_ids)} unique albums across all playlists in folder: {media.name}") + + if not album_ids: + logger_gui.info("No albums found to download.") + return + + # Load full album objects with rate limiting + albums_dict = self._load_albums_with_rate_limiting(album_ids) + + if not albums_dict: + logger_gui.error("Failed to load any albums.") + return + + # Queue the albums for download + self._queue_loaded_albums(albums_dict) + + logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from folder: {media.name}") + + except Exception as e: + logger_gui.exception(f"Error downloading albums from folder: {e}") + logger_gui.error("Failed to download albums from folder. See log for details.") + + def on_download_favorites(self, point: QtCore.QPoint) -> None: + """Download all items from a Favorites category. + + Args: + point (QPoint): The point in the tree where the favorites item was right-clicked. + """ + try: + # Get and validate the favorites item + item = self.tr_lists_user.itemAt(point) + media = get_user_list_media_item(item) + + if not isinstance(media, str): + logger_gui.error("Please select a favorites category.") + return + + # Get the favorites category name for logging + favorite_name = FAVORITES.get(media, {}).get("name", media) + logger_gui.info(f"Fetching all items from favorites: {favorite_name}") + + # Use the factory to get the appropriate favorites function + favorite_function = favorite_function_factory(self.tidal, media) + + # Fetch all items from this favorites category + media_items = favorite_function() + + if not media_items: + logger_gui.info(f"No items found in favorites: {favorite_name}") + return + + logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}") + + # Queue each item for download + queued_count = 0 + + for media_item in media_items: + queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(media_item) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + queued_count += 1 + + logger_gui.info(f"✅ Successfully queued {queued_count} items from favorites: {favorite_name}") + + except Exception as e: + logger_gui.exception(f"Error downloading favorites: {e}") + logger_gui.error("Failed to download favorites. See log for details.") + + def _download_albums_from_favorites_albums(self, media_items: list, favorite_name: str) -> None: + """Download albums from favorite albums list. + + Args: + media_items (list): List of favorite albums. + favorite_name (str): Name of the favorites category for logging. + """ + logger_gui.info(f"Queueing {len(media_items)} albums from favorites: {favorite_name}") + albums_dict = {album.id: album for album in media_items if isinstance(album, Album) and album.id} + self._queue_loaded_albums(albums_dict) + logger_gui.info(f"✅ Successfully queued {len(albums_dict)} albums from favorites: {favorite_name}") + + def _download_albums_from_favorites_artists(self, media_items: list, favorite_name: str) -> None: + """Download albums from favorite artists list. + + Args: + media_items (list): List of favorite artists. + favorite_name (str): Name of the favorites category for logging. + """ + logger_gui.info(f"Fetching albums from {len(media_items)} artists...") + all_albums = {} + + for artist in media_items: + if isinstance(artist, Artist): + try: + artist_albums = items_results_all(artist) + for album in artist_albums: + if isinstance(album, Album) and album.id: + all_albums[album.id] = album + logger_gui.debug(f"Found {len(artist_albums)} albums from artist: {artist.name}") + except Exception as e: + logger_gui.error(f"Error getting albums from artist '{artist.name}': {e}") + continue + + if not all_albums: + logger_gui.info("No albums found from favorite artists.") + return + + logger_gui.info(f"Found {len(all_albums)} unique albums from favorite artists") + self._queue_loaded_albums(all_albums) + logger_gui.info(f"✅ Successfully queued {len(all_albums)} albums from favorites: {favorite_name}") + + def _download_albums_from_favorites_tracks(self, media_items: list, favorite_name: str) -> None: + """Download albums from favorite tracks/videos/mixes list. + + Args: + media_items (list): List of favorite tracks/videos/mixes. + favorite_name (str): Name of the favorites category for logging. + """ + logger_gui.info("Extracting albums from tracks...") + album_ids = self._extract_album_ids_from_tracks(media_items) + + if not album_ids: + logger_gui.info(f"No albums found in favorites: {favorite_name}") + return + + logger_gui.info(f"Found {len(album_ids)} unique albums. Loading with rate limiting...") + + # Load full album objects with rate limiting + albums_dict = self._load_albums_with_rate_limiting(album_ids) + + if not albums_dict: + logger_gui.error("Failed to load any albums from favorites.") + return + + # Queue the albums for download + self._queue_loaded_albums(albums_dict) + logger_gui.info(f"✅ Successfully queued {len(albums_dict)} unique albums from favorites: {favorite_name}") + + def on_download_albums_from_favorites(self, point: QtCore.QPoint) -> None: + """Download all unique albums from items in a Favorites category. + + Args: + point (QPoint): The point in the tree where the favorites item was right-clicked. + """ + try: + # Get and validate the favorites item + item = self.tr_lists_user.itemAt(point) + media = get_user_list_media_item(item) + + if not isinstance(media, str): + logger_gui.error("Please select a favorites category.") + return + + # Get the favorites category name for logging + favorite_name = FAVORITES.get(media, {}).get("name", media) + logger_gui.info(f"Fetching all items from favorites: {favorite_name}") + + # Use the factory to get the appropriate favorites function + favorite_function = favorite_function_factory(self.tidal, media) + + # Fetch all items from this favorites category + media_items = favorite_function() + + if not media_items: + logger_gui.info(f"No items found in favorites: {favorite_name}") + return + + logger_gui.info(f"Found {len(media_items)} items in favorites: {favorite_name}") + + # Delegate to appropriate handler based on favorites type + if media == "fav_albums": + self._download_albums_from_favorites_albums(media_items, favorite_name) + elif media == "fav_artists": + self._download_albums_from_favorites_artists(media_items, favorite_name) + else: + self._download_albums_from_favorites_tracks(media_items, favorite_name) + + except Exception as e: + logger_gui.exception(f"Error downloading albums from favorites: {e}") + logger_gui.error("Failed to download albums from favorites. See log for details.") + + def search_populate_results(self, query: str, type_media: Any) -> None: + """Populate the results tree with search results. + + Args: + query (str): The search query. + type_media (SearchTypes): The type of media to search for. + """ + results: list[ResultItem] = self.search(query, [type_media]) + + self.populate_tree_results(results) + + def populate_tree_results(self, results: list[ResultItem], parent: QtGui.QStandardItem | None = None) -> None: + """Populate the results tree with ResultItem objects. + + Args: + results (list[ResultItem]): The results to display. + parent (QStandardItem, optional): Parent item for nested results. Defaults to None. + """ + if not parent: + self.model_tr_results.removeRows(0, self.model_tr_results.rowCount()) + + # Count how many digits the list length has, + count_digits: int = int(math.log10(len(results) if results else 1)) + 1 + + for item in results: + child: tuple = self.populate_tree_result_child(item=item, index_count_digits=count_digits) + + if parent: + parent.appendRow(child) + else: + self.s_tr_results_add_top_level_item.emit(child) + + def populate_tree_result_child(self, item: ResultItem, index_count_digits: int) -> Sequence[QtGui.QStandardItem]: + """Create a row of QStandardItems for a ResultItem. + + Args: + item (ResultItem): The result item. + index_count_digits (int): Number of digits for index formatting. + + Returns: + Sequence[QStandardItem]: The row of items. + """ + duration: str = "" + + # TODO: Duration needs to be calculated later to properly fill with zeros. + if item.duration_sec > -1: + # Format seconds to mm:ss. + m, s = divmod(item.duration_sec, 60) + duration: str = f"{m:02d}:{s:02d}" + + # Since sorting happens only by string, we need to pad the index and add 1 (to avoid start at 0) + index: str = str(item.position + 1).zfill(index_count_digits) + + # Populate child + child_index: QtGui.QStandardItem = QtGui.QStandardItem(index) + # TODO: Move to own method + child_obj: QtGui.QStandardItem = QtGui.QStandardItem() + + child_obj.setData(item.obj, QtCore.Qt.ItemDataRole.UserRole) + # set_results_media(child, item.obj) + + child_artist: QtGui.QStandardItem = QtGui.QStandardItem(item.artist) + child_title: QtGui.QStandardItem = QtGui.QStandardItem(item.title) + child_album: QtGui.QStandardItem = QtGui.QStandardItem(item.album) + child_duration: QtGui.QStandardItem = QtGui.QStandardItem(duration) + child_quality: QtGui.QStandardItem = QtGui.QStandardItem(item.quality) + child_date: QtGui.QStandardItem = QtGui.QStandardItem( + item.date_user_added if item.date_user_added != "" else item.date_release + ) + + if isinstance(item.obj, Mix | Playlist | Album | Artist): + # Add a disabled dummy child, so expansion arrow will appear. This Child will be replaced on expansion. + child_dummy: QtGui.QStandardItem = QtGui.QStandardItem() + + child_dummy.setEnabled(False) + child_index.appendRow(child_dummy) + + return ( + child_index, + child_obj, + child_artist, + child_title, + child_album, + child_duration, + child_quality, + child_date, + ) + + def on_tr_results_add_top_level_item(self, item_child: Sequence[QtGui.QStandardItem]): + """Add a top-level item to the results tree model. + + Args: + item_child (Sequence[QStandardItem]): The row to add. + """ + self.model_tr_results.appendRow(item_child) + + def on_settings_save(self) -> None: + """Save settings and re-apply them to the GUI.""" + self.settings.save() + self.apply_settings(self.settings) + self._init_dl() + + def search(self, query: str, types_media: list[Any]) -> list[ResultItem]: + """Perform a search and return a list of ResultItems. + + Args: + query (str): The search query. + types_media (list[Any]): The types of media to search for. + + Returns: + list[ResultItem]: The search results. + """ + query_clean: str = query.strip() + + # If a direct link was searched for, skip search and create the object from the link directly. + if "http" in query_clean: + query_clean: str = 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.tidal.session, media_type, item_id) + except Exception: + logger_gui.error(f"Media not found (ID: {item_id}). Maybe it is not available anymore.") + + media = None + + result_search = {"direct": [media]} + else: + result_search: dict[str, list[SearchTypes]] = search_results_all( + session=self.tidal.session, needle=query_clean, types_media=types_media + ) + + result: list[ResultItem] = [] + + for _media_type, l_media in result_search.items(): + if isinstance(l_media, list): + result = result + 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. + + Args: + items (list[SearchTypes]): List of search result items. + + Returns: + list[ResultItem]: List of ResultItem models. + """ + result: list[ResultItem] = [] + + for idx, item in enumerate(items): + result_item = self._to_result_item(idx, item) + + if result_item is not None: + result.append(result_item) + + return result + + def _to_result_item(self, idx: int, item) -> ResultItem | None: + """Helper to convert a single item to ResultItem, or None if not valid. + + Args: + idx (int): Index of the item. + item: The item to convert. + + Returns: + ResultItem | None: The converted ResultItem or None if not valid. + """ + if not item or (hasattr(item, "available") and not item.available): + return None + + # Prepare common data + 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) + + # Map item types to their conversion methods + type_handlers = { + Track: lambda: self._result_item_from_track(idx, item, explicit, date_user_added, date_release), + Video: lambda: self._result_item_from_video(idx, item, explicit, date_user_added, date_release), + Playlist: lambda: self._result_item_from_playlist(idx, item, date_user_added, date_release), + Album: lambda: self._result_item_from_album(idx, item, explicit, date_user_added, date_release), + Mix: lambda: self._result_item_from_mix(idx, item, date_user_added, date_release), + Artist: lambda: self._result_item_from_artist(idx, item, date_user_added, date_release), + Folder: lambda: self._result_item_from_folder(idx, item, date_user_added), + } + + # Find and execute the appropriate handler + for item_type, handler in type_handlers.items(): + if isinstance(item, item_type): + return handler() + + return None + + def _get_date_release(self, item) -> str: + """Get the release date string for an item. + + Args: + item: The item to extract the release date from. + + Returns: + str: The formatted release date or empty string. + """ + 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, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + """Create a ResultItem from a Track. + + Args: + idx (int): Index of the item. + item: The Track item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + + 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"{name_builder_title(item)}{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, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + """Create a ResultItem from a Video. + + Args: + idx (int): Index of the item. + item: The Video item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + return ResultItem( + position=idx, + artist=name_builder_artist(item), + title=f"{name_builder_title(item)}{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, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Playlist. + + Args: + idx (int): Index of the item. + item: The Playlist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + 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, explicit: str, date_user_added: str, date_release: str + ) -> ResultItem: + """Create a ResultItem from an Album. + + Args: + idx (int): Index of the item. + item: The Album item. + explicit (str): Explicit tag. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + 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, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from a Mix. + + Args: + idx (int): Index of the item. + item: The Mix item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + return ResultItem( + position=idx, + artist=item.sub_title, + title=item.title, + album="", + duration_sec=-1, # TODO: Calculate total duration. + obj=item, + quality="", + explicit=False, + date_user_added=date_user_added, + date_release=date_release, + ) + + def _result_item_from_artist(self, idx: int, item, date_user_added: str, date_release: str) -> ResultItem: + """Create a ResultItem from an Artist. + + Args: + idx (int): Index of the item. + item: The Artist item. + date_user_added (str): Date user added. + date_release (str): Release date. + + Returns: + ResultItem: The constructed ResultItem. + """ + 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, date_user_added: str) -> ResultItem: + """Create a ResultItem from a Folder. + + Args: + idx (int): Index of the item. + item (Folder): The Folder item. + date_user_added (str): Date user added. + + Returns: + ResultItem: The constructed ResultItem. + """ + total_items: int = 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="", + ) + + def media_to_queue_download_model( + self, media: Artist | Track | Video | Album | Playlist | Mix + ) -> QueueDownloadItem | bool: + """Convert a media object to a QueueDownloadItem for the download queue. + + Args: + media (Artist | Track | Video | Album | Playlist | Mix): The media object. + + Returns: + QueueDownloadItem | bool: The queue item or False if not available. + """ + result: QueueDownloadItem | False + name: str = "" + quality_audio: Quality = self.settings.data.quality_audio + quality_video: QualityVideo = self.settings.data.quality_video + explicit: str = "" + + # Check if item is available on TIDAL. + # Note: Some albums have available=None, which should be treated as available + if hasattr(media, "available") and media.available is False: + return False + + # Set "Explicit" tag + if isinstance(media, Track | Video | Album): + explicit = " 🅴" if media.explicit else "" + + # Build name and set quality + 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 + + # Determine actual quality. + if isinstance(media, Track | Album): + quality_highest: Quality = 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: + result = QueueDownloadItem( + name=name, + quality_audio=quality_audio, + quality_video=quality_video, + type_media=type(media).__name__, + status=QueueDownloadStatus.Waiting, + obj=media, + ) + else: + result = False + + return result + + def _init_signals(self) -> None: + """Connect signals to their respective slots.""" + self.pb_download.clicked.connect(lambda: self.thread_it(self.on_download_results)) + self.pb_download_list.clicked.connect(lambda: self.thread_it(self.on_download_list_media)) + self.pb_reload_user_lists.clicked.connect(lambda: self.thread_it(self.tidal_user_lists)) + self.pb_queue_download_clear_all.clicked.connect(self.on_queue_download_clear_all) + self.pb_queue_download_clear_finished.clicked.connect(self.on_queue_download_clear_finished) + self.pb_queue_download_remove.clicked.connect(self.on_queue_download_remove) + self.pb_queue_download_toggle.clicked.connect(self.on_pb_queue_download_toggle) + self.l_search.returnPressed.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.pb_search.clicked.connect( + lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData()) + ) + self.cb_quality_audio.currentIndexChanged.connect(self.on_quality_set_audio) + self.cb_quality_video.currentIndexChanged.connect(self.on_quality_set_video) + self.tr_lists_user.itemClicked.connect(self.on_list_items_show) + self.tr_lists_user.itemExpanded.connect(self.on_tr_lists_user_expanded) + self.s_spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start) + self.s_spinner_stop.connect(self.on_spinner_stop) + self.s_item_advance.connect(self.on_progress_item) + self.s_item_name.connect(self.on_progress_item_name) + self.s_list_name.connect(self.on_progress_list_name) + self.s_list_advance.connect(self.on_progress_list) + self.s_pb_reset.connect(self.on_progress_reset) + self.s_populate_tree_lists.connect(self.on_populate_tree_lists) + self.s_populate_folder_children.connect(self.on_populate_folder_children) + self.s_statusbar_message.connect(self.on_statusbar_message) + self.s_tr_results_add_top_level_item.connect(self.on_tr_results_add_top_level_item) + self.s_settings_save.connect(self.on_settings_save) + self.s_pb_reload_status.connect(self.button_reload_status) + self.s_update_check.connect(lambda: self.thread_it(self.on_update_check)) + self.s_update_show.connect(self.on_version) + + # Menubar + self.a_exit.triggered.connect(self.close) + self.a_version.triggered.connect(self.on_version) + self.a_preferences.triggered.connect(self.on_preferences) + self.a_logout.triggered.connect(self.on_logout) + self.a_updates_check.triggered.connect(lambda: self.on_update_check(False)) + + # Results + self.tr_results.expanded.connect(self.on_tr_results_expanded) + self.tr_results.clicked.connect(self.on_result_item_clicked) + self.tr_results.doubleClicked.connect(lambda: self.thread_it(self.on_download_results)) + + # Download Queue + self.tr_queue_download.itemClicked.connect(self.on_queue_download_item_clicked) + self.s_queue_download_item_downloading.connect(self.on_queue_download_item_downloading) + self.s_queue_download_item_finished.connect(self.on_queue_download_item_finished) + self.s_queue_download_item_failed.connect(self.on_queue_download_item_failed) + self.s_queue_download_item_skipped.connect(self.on_queue_download_item_skipped) + + def _init_buttons(self) -> None: + """Initialize the state of the download buttons.""" + self.pb_queue_download_run() + + def on_logout(self) -> None: + """Log out from TIDAL and close the application.""" + result: bool = self.tidal.logout() + + if result: + sys.exit(0) + + def on_progress_list(self, value: float) -> None: + """Update the progress of the list progress bar. + + Args: + value (float): The progress value as a percentage. + """ + self.pb_list.setValue(int(math.ceil(value))) + + def on_progress_item(self, value: float) -> None: + """Update the progress of the item progress bar. + + Args: + value (float): The progress value as a percentage. + """ + self.pb_item.setValue(int(math.ceil(value))) + + def on_progress_item_name(self, value: str) -> None: + """Set the format of the item progress bar. + + Args: + value (str): The item name. + """ + self.pb_item.setFormat(f"%p% {value}") + + def on_progress_list_name(self, value: str) -> None: + """Set the format of the list progress bar. + + Args: + value (str): The list name. + """ + self.pb_list.setFormat(f"%p% {value}") + + def on_quality_set_audio(self, index: int) -> None: + """Set the audio quality for downloads. + + Args: + index: The index of the selected quality in the combo box. + """ + quality_data = self.cb_quality_audio.itemData(index) + + self.settings.data.quality_audio = Quality(quality_data) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_quality_set_video(self, index: int) -> None: + """Set the video quality for downloads. + + Args: + index: The index of the selected quality in the combo box. + """ + self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index)) + self.settings.save() + + if self.tidal: + self.tidal.settings_apply() + + def on_tr_lists_user_expanded(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Handle expansion of folders in the user lists tree. + + Args: + item (QTreeWidgetItem): The expanded tree item. + """ + # Check if it's a first-time expansion (has disabled dummy child) + if item.childCount() > 0 and item.child(0).isDisabled(): + # Run in thread to avoid blocking UI + self.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. + + Args: + parent_item (QTreeWidgetItem): The parent folder item. + """ + folder: Folder | None = get_user_list_media_item(parent_item) + + if not isinstance(folder, Folder): + return + + # Show spinner while loading + self.s_spinner_start.emit(self.tr_lists_user) + + try: + # Fetch folder contents + folders, playlists = self._fetch_folder_contents(folder) + + # Emit signal to populate in main thread + self.s_populate_folder_children.emit(parent_item, folders, playlists) + + finally: + self.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 (signal handler). + + Args: + parent_item (QTreeWidgetItem): The parent folder item. + folders (list[Folder]): List of sub-folders. + playlists (list[Playlist]): List of playlists. + """ + # Remove dummy child + parent_item.takeChild(0) + + # Add sub-folders as children + 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) + + # Add dummy child for potential sub-folders + dummy = QtWidgets.QTreeWidgetItem(twi_child) + dummy.setDisabled(True) + + # Add playlists as children + for playlist in playlists: + twi_child = QtWidgets.QTreeWidgetItem(parent_item) + name = playlist.name if playlist.name else "" + twi_child.setText(0, name) + 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 (sub-folders and playlists) of a folder. + + Args: + folder (Folder): The folder to fetch contents for. + + Returns: + tuple[list[Folder], list[Playlist]]: Sub-folders and playlists within the folder. + """ + folder_id = folder.id if folder.id else "root" + + # Fetch sub-folders with manual pagination + offset = 0 + limit = 50 + folders = [] + + while True: + batch = self.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 + + # Fetch playlists in this folder using folder.items() method + offset = 0 + playlists = [] + + 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. + + Args: + folder (Folder): The folder to fetch playlists from. + + Returns: + list[Playlist]: List of playlists in the folder. + """ + # Use existing method to fetch folder contents + # Since folders can't contain folders, we ignore the folders return value + _, playlists = self._fetch_folder_contents(folder) + + logger_gui.debug(f"Found {len(playlists)} playlists in folder: {folder.name}") + + return playlists + + def _get_playlist_tracks(self, playlist: Playlist | UserPlaylist | Mix) -> list[Track]: + """Fetch all tracks from a playlist. + + Args: + playlist (Playlist | UserPlaylist | Mix): The playlist to fetch tracks from. + + Returns: + list[Track]: List of tracks in the playlist. + """ + playlist_name = getattr(playlist, "name", "unknown") + logger_gui.debug(f"Fetching tracks from playlist: {playlist_name}") + media_items = items_results_all(playlist) + + # Filter for Track objects only (items_results_all may return Videos too) + tracks = [item for item in media_items if isinstance(item, Track)] + + logger_gui.debug(f"Found {len(tracks)} tracks in playlist: {playlist_name}") + + return tracks + + def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem) -> None: + """Show the items in the selected playlist or mix. + + Args: + item (QtWidgets.QTreeWidgetItem): The selected tree widget item. + """ + self.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. + + Args: + item (QtWidgets.QTreeWidgetItem): The tree widget item representing a playlist, mix, or folder. + """ + media_list: Album | Playlist | Folder | str = get_user_list_media_item(item) + + # Only if clicked item is not a top level item. + if media_list: + # Show spinner while loading list + self.s_spinner_start.emit(self.tr_results) + try: + if isinstance(media_list, Folder): + # Show folder contents + self._show_folder_contents(media_list) + elif isinstance(media_list, str) and media_list.startswith("fav_"): + function_list = favorite_function_factory(self.tidal, media_list) + self.list_items_show_result(favorite_function=function_list) + else: + self.list_items_show_result(media_list) + # Load cover asynchronously to avoid blocking the GUI + self.thread_it(self.cover_show, media_list) + finally: + self.s_spinner_stop.emit() + + def _show_folder_contents(self, folder: Folder) -> None: + """Display folder contents (nested playlists/folders) in results pane. + + Args: + folder (Folder): The folder to display contents for. + """ + # Fetch folder contents using the shared helper method + folders, playlists = self._fetch_folder_contents(folder) + + # Combine folders and playlists + items = folders + playlists + + # Convert to ResultItems and display + result = self.search_result_to_model(items) + self.populate_tree_results(result) + + def on_result_item_clicked(self, index: QtCore.QModelIndex) -> None: + """Handle the event when a result item is clicked. + + Args: + index (QtCore.QModelIndex): The index of the clicked item. + """ + media: Track | Video | Album | Artist = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + # Load cover asynchronously to avoid blocking the GUI + self.thread_it(self.cover_show, media) + + def on_queue_download_item_clicked(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: + """Handle the event when a queue download item is clicked. + + Args: + item (QtWidgets.QTreeWidgetItem): The clicked tree widget item. + column (int): The column index of the clicked item. + """ + media: Track | Video | Album | Artist | Mix | Playlist = get_queue_download_media(item) + + # Load cover asynchronously to avoid blocking the GUI + self.thread_it(self.cover_show, media) + + def cover_show(self, media: Album | Playlist | Track | Video | Album | Artist) -> None: + """Show the cover image of the selected media item. + + Args: + media (Album | Playlist | Track | Video | Album | Artist): The media item. + """ + cover_url: str = "" + # Show spinner in the cover label itself + parent_widget = self.l_pm_cover + + # Show spinner while loading + self.s_spinner_start.emit(parent_widget) + + try: + try: + cover_url = media.album.image() + except Exception: + # Only call image() if it exists + if hasattr(media, "image") and callable(getattr(media, "image", None)): + try: + cover_url = media.image() + except Exception: + logger_gui.info(f"No cover available (media ID: {getattr(media, 'id', 'unknown')}).") + else: + cover_url = None + + logger_gui.info(f"No cover available (media ID: {getattr(media, 'id', 'unknown')}).") + + if cover_url and self.cover_url_current != cover_url: + self.cover_url_current = cover_url + data_cover: bytes = Download.cover_data(cover_url) + pixmap: QtGui.QPixmap = QtGui.QPixmap() + pixmap.loadFromData(data_cover) + self.l_pm_cover.setPixmap(pixmap) + elif not cover_url: + path_image: str = resource_path("tidal_dl_ng/ui/default_album_image.png") + self.l_pm_cover.setPixmap(QtGui.QPixmap(path_image)) + finally: + self.s_spinner_stop.emit() + + def list_items_show_result( + self, + media_list: Album | Playlist | Mix | Artist | None = None, + point: QtCore.QPoint | None = None, + parent: QtGui.QStandardItem | None = None, + favorite_function: Callable | None = None, + ) -> None: + """Populate the results tree with the items of a media list. + + Args: + media_list (Album | Playlist | Mix | Artist | None, optional): The media list to show. Defaults to None. + point (QPoint | None, optional): The point in the tree. Defaults to None. + parent (QStandardItem | None, optional): Parent item for nested results. Defaults to None. + favorite_function (Callable | None, optional): Function to fetch favorite items. Defaults to None. + """ + if point: + item = self.tr_lists_user.itemAt(point) + media_list = get_user_list_media_item(item) + + # Get all results + if favorite_function or isinstance(media_list, str): + if isinstance(media_list, str): + favorite_function = favorite_function_factory(self.tidal, media_list) + + media_items: list[Track | Video | Album] = favorite_function() + else: + media_items: list[Track | Video | Album] = items_results_all(media_list) + + result: list[ResultItem] = self.search_result_to_model(media_items) + + self.populate_tree_results(result, parent=parent) + + def thread_it(self, fn: Callable, *args, **kwargs) -> None: + """Run a function in a separate thread. + + Args: + fn (Callable): The function to run. + *args: Positional arguments for the function. + **kwargs: Keyword arguments for the function. + """ + # Any other args, kwargs are passed to the run function + worker = Worker(fn, *args, **kwargs) + + # Execute + self.threadpool.start(worker) + + 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})" + ) + + def on_queue_download_clear_finished(self) -> None: + """Clear finished items from the download queue.""" + self.on_clear_queue_download(f"[{QueueDownloadStatus.Finished}]") + + def on_clear_queue_download(self, regex: str) -> None: + """Clear items from the download queue matching the given regex. + + Args: + regex (str): Regular expression to match items. + """ + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + regex, QtCore.Qt.MatchFlag.MatchRegularExpression, column=0 + ) + + for item in items: + self.tr_queue_download.takeTopLevelItem(self.tr_queue_download.indexOfTopLevelItem(item)) + + def on_queue_download_remove(self) -> None: + """Remove selected items from the download queue.""" + items: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.selectedItems() + + if len(items) == 0: + 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.tr_queue_download.takeTopLevelItem(self.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. + + :return: None + """ + 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.pb_queue_download_toggle.setIcon(icon) + self.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.pb_queue_download_toggle.setIcon(icon) + self.pb_queue_download_toggle.setStyleSheet("background-color: #218838; color: #fff") + + # TODO: Must happen in main thread. Do not thread this. + def on_download_results(self) -> None: + """Download the selected results in the results tree.""" + items: [HumanProxyModel | None] = self.tr_results.selectionModel().selectedRows() + + if len(items) == 0: + logger_gui.error("Please select a row first.") + else: + for item in items: + media: Track | Album | Playlist | Video | Artist = get_results_media_item( + item, self.proxy_tr_results, self.model_tr_results + ) + queue_dl_item: QueueDownloadItem = self.media_to_queue_download_model(media) + + if queue_dl_item: + self.queue_download_media(queue_dl_item) + + def queue_download_media(self, queue_dl_item: QueueDownloadItem) -> None: + """Add a media item to the download queue. + + Args: + queue_dl_item (QueueDownloadItem): The item to add to the queue. + """ + # Populate child + child: QtWidgets.QTreeWidgetItem = 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, queue_dl_item.quality_audio) + child.setText(5, queue_dl_item.quality_video) + self.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: list[QtWidgets.QTreeWidgetItem | None] = self.tr_queue_download.findItems( + QueueDownloadStatus.Waiting, QtCore.Qt.MatchFlag.MatchExactly, column=0 + ) + + if len(items) > 0: + result: QueueDownloadStatus + item: QtWidgets.QTreeWidgetItem = items[0] + media: Track | Album | Playlist | Video | Mix | Artist = get_queue_download_media(item) + quality_audio: Quality = get_queue_download_quality_audio(item) + quality_video: QualityVideo = get_queue_download_quality_video(item) + + try: + self.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.s_queue_download_item_finished.emit(item) + elif result == QueueDownloadStatus.Skipped: + self.s_queue_download_item_skipped.emit(item) + except Exception as e: + logger_gui.error(e) + self.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'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + 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'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + 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'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + 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'. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + """ + 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. + + Args: + item (QtWidgets.QTreeWidgetItem): The item to update. + status (str): The status text. + """ + 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. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item(s) to download. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ + result: QueueDownloadStatus + items_media: [Track | Album | Playlist | Video | Mix | Artist] + + if isinstance(media, Artist): + items_media: [Album] = items_results_all(media) + else: + items_media = [media] + + download_delay: bool = bool(isinstance(media, Track | Video) and self.settings.data.download_delay) + + for item_media in items_media: + result = self.download( + item_media, + self.dl, + delay_track=download_delay, + quality_audio=quality_audio, + quality_video=quality_video, + ) + + return result + + def download( + self, + media: Track | Album | Playlist | Video | Mix | Artist, + 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. + + Args: + media (Track | Album | Playlist | Video | Mix | Artist): The media item to download. + dl (Download): The Download object to use. + delay_track (bool, optional): Whether to apply download delay. Defaults to False. + quality_audio (Quality | None, optional): Desired audio quality. Defaults to None. + quality_video (QualityVideo | None, optional): Desired video quality. Defaults to None. + + Returns: + QueueDownloadStatus: The status of the download operation. + """ + result_dl: bool + path_file: str + result: QueueDownloadStatus + self.s_pb_reset.emit() + self.s_statusbar_message.emit(StatusbarMessage(message="Download started...")) + + file_template = get_format_template(media, self.settings) + + 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, + ) + + # Dummy values + result_dl = True + path_file = "dummy" + + self.s_statusbar_message.emit(StatusbarMessage(message="Download finished.", timeout=2000)) + + if result_dl and path_file: + result = QueueDownloadStatus.Finished + elif not result_dl and path_file: + result = QueueDownloadStatus.Skipped + else: + result = QueueDownloadStatus.Failed + + return result + + def on_version( + self, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest | None = None + ) -> None: + """Show the version information dialog. + + Args: + update_check (bool, optional): Whether to check for updates. Defaults to False. + update_available (bool, optional): Whether an update is available. Defaults to False. + update_info (ReleaseLatest | None, optional): Information about the latest release. Defaults to None. + """ + DialogVersion(self, update_check, update_available, update_info) + + def on_preferences(self) -> None: + """Open the preferences dialog.""" + # Prevent multiple instances. Reuse existing dialog if still visible. + if self.dialog_preferences and self.dialog_preferences.isVisible(): + # Bring existing dialog to front. + self.dialog_preferences.raise_() + self.dialog_preferences.activateWindow() + return + + # Clear stale reference if dialog was closed. + if self.dialog_preferences and not self.dialog_preferences.isVisible(): + self.dialog_preferences = None + + # Create new non-blocking preferences dialog. + dlg = DialogPreferences(settings=self.settings, settings_save=self.s_settings_save, parent=self) + dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) + + # Disable action while dialog open. + self.a_preferences.setEnabled(False) + + def _on_destroyed(): + self.dialog_preferences = None + self.a_preferences.setEnabled(True) + + dlg.destroyed.connect(_on_destroyed) + self.dialog_preferences = dlg + dlg.show() + + def on_tr_results_expanded(self, index: QtCore.QModelIndex) -> None: + """Handle the event when a result item group is expanded. + + Args: + index (QtCore.QModelIndex): The index of the expanded item. + """ + self.thread_it(self.tr_results_expanded, index) + + def tr_results_expanded(self, index: QtCore.QModelIndex) -> None: + """Load and display the children of an expanded result item. + + Args: + index (QtCore.QModelIndex): The index of the expanded item. + """ + # If the child is a dummy the list_item has not been expanded before + item: QtGui.QStandardItem = self.model_tr_results.itemFromIndex(self.proxy_tr_results.mapToSource(index)) + load_children: bool = not item.child(0, 0).isEnabled() + + if load_children: + item.removeRow(0) + media_list: list[Mix | Album | Playlist | Artist] = get_results_media_item( + index, self.proxy_tr_results, self.model_tr_results + ) + + # Show spinner while loading children + self.s_spinner_start.emit(self.tr_results) + + try: + self.list_items_show_result(media_list=media_list, parent=item) + finally: + self.s_spinner_stop.emit() + + def button_reload_status(self, status: bool) -> None: + """Update the reload button's state and text. + + Args: + status (bool): The new status. + """ + button_text: str = "Reloading..." + + if status: + button_text = "Reload" + + self.pb_reload_user_lists.setEnabled(status) + self.pb_reload_user_lists.setText(button_text) + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + """Handle the close event of the main window. + + Args: + event (QtGui.QCloseEvent): The close event. + """ + # Save the main window size and position + self.settings.data.window_x = self.x() + self.settings.data.window_y = self.y() + self.settings.data.window_w = self.width() + self.settings.data.window_h = self.height() + self.settings.save() + + self.shutdown = True + + handling_app: HandlingApp = HandlingApp() + handling_app.event_abort.set() + + event.accept() + + def thread_download_album_from_track(self, point: QtCore.QPoint) -> None: + """Starts the download of the full album from a selected track in a new thread. + + Args: + point (QPoint): The point in the tree where the user clicked. + """ + self.thread_it(self.on_download_album_from_track, point) + + def on_download_album_from_track(self, point: QtCore.QPoint) -> None: + """Adds the album associated with a selected track to the download queue. + + This method retrieves the album from a track selected in the results tree and attempts to add it to the download queue. If the album cannot be retrieved or an error occurs, a warning or error is logged. + + Args: + point (QtCore.QPoint): The point in the results tree where the user clicked. + """ + index: QtCore.QModelIndex = self.tr_results.indexAt(point) + media_track: Track = get_results_media_item(index, self.proxy_tr_results, self.model_tr_results) + + # Ensure we have a track and an album object with an ID + if isinstance(media_track, Track) and media_track.album and media_track.album.id: + try: + # Use the album ID from the track to fetch the FULL album object from TIDAL + full_album_object = self.tidal.session.album(media_track.album.id) + + # Convert the full album object into a queue item + queue_dl_item: QueueDownloadItem | None = self.media_to_queue_download_model(full_album_object) + + if queue_dl_item: + # Add the item to the download queue + self.queue_download_media(queue_dl_item) + else: + logger_gui.warning(f"Failed to create a queue item for album ID: {full_album_object.id}") + except Exception as e: + logger_gui.error(f"Could not fetch the full album from TIDAL. Error: {e}") + else: + logger_gui.warning("Could not retrieve album information from the selected track.") + + +# TODO: Comment with Google Docstrings. +def gui_activate(tidal: Tidal | None = None): + # Set dark theme and create QT app. + qdarktheme.enable_hi_dpi() + + app = QtWidgets.QApplication(sys.argv) + + # Fix for Windows: Tooltips have bright font color + # https://github.com/5yutan5/PyQtDarkTheme/issues/239 + # qdarktheme.setup_theme() + qdarktheme.setup_theme(additional_qss="QToolTip { border: 0px; }") + + # Create icon object and apply it to app window. + icon: QtGui.QIcon = QtGui.QIcon() + + icon.addFile("tidal_dl_ng/ui/icon16.png", QtCore.QSize(16, 16)) + icon.addFile("tidal_dl_ng/ui/icon32.png", QtCore.QSize(32, 32)) + icon.addFile("tidal_dl_ng/ui/icon48.png", QtCore.QSize(48, 48)) + icon.addFile("tidal_dl_ng/ui/icon64.png", QtCore.QSize(64, 64)) + icon.addFile("tidal_dl_ng/ui/icon256.png", QtCore.QSize(256, 256)) + icon.addFile("tidal_dl_ng/ui/icon512.png", QtCore.QSize(512, 512)) + app.setWindowIcon(icon) + + # This bit gets the taskbar icon working properly in Windows + if sys.platform.startswith("win"): + import ctypes + + # Make sure Pyinstaller icons are still grouped + if not sys.argv[0].endswith(".exe"): + # Arbitrary string + my_app_id: str = "exislow.tidal.dl-ng." + __version__ + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id) + + window = MainWindow(tidal=tidal) + + window.show() + # Check for updates + window.s_update_check.emit(True) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + gui_activate() diff --git a/tidal_dl_ng/helper/__init__.py b/tidal_dl_ng/helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/helper/decorator.py b/tidal_dl_ng/helper/decorator.py new file mode 100644 index 0000000..ba0f417 --- /dev/null +++ b/tidal_dl_ng/helper/decorator.py @@ -0,0 +1,22 @@ +from typing import ClassVar + + +class SingletonMeta(type): + """ + The Singleton class can be implemented in different ways in Python. Some + possible methods include: base class, decorator, metaclass. We will use the + metaclass because it is best suited for this purpose. + """ + + _instances: ClassVar[dict] = {} + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + + return cls._instances[cls] diff --git a/tidal_dl_ng/helper/decryption.py b/tidal_dl_ng/helper/decryption.py new file mode 100644 index 0000000..2f38fc5 --- /dev/null +++ b/tidal_dl_ng/helper/decryption.py @@ -0,0 +1,63 @@ +import base64 +import pathlib + +from Crypto.Cipher import AES +from Crypto.Util import Counter + + +def decrypt_security_token(security_token: str) -> (str, str): + """ + The `decrypt_security_token` function decrypts a security token into a key and nonce pair using AES + encryption. + + Args: + security_token (str): The `security_token` parameter in the `decrypt_security_token` function is a + string that represents an encrypted security token. This function decrypts the security token into a + key and nonce pair using AES encryption. security_token should match the securityToken value from the web response. + + Returns: + The `decrypt_security_token` function returns a tuple containing the key and nonce extracted from + the decrypted security token. + """ + + # Do not change this + master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" + + # Decode the base64 strings to ascii strings + master_key = base64.b64decode(master_key) + security_token = base64.b64decode(security_token) + + # Get the IV from the first 16 bytes of the securityToken + iv = security_token[:16] + encrypted_st = security_token[16:] + + # Initialize decryptor + decryptor = AES.new(master_key, AES.MODE_CBC, iv) + + # Decrypt the security token + decrypted_st = decryptor.decrypt(encrypted_st) + + # Get the audio stream decryption key and nonce from the decrypted security token + key = decrypted_st[:16] + nonce = decrypted_st[16:24] + + return key, nonce + + +def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None: + """ + Decrypts an encrypted MQA file given the file, key and nonce. + TODO: Is it really only necessary for MQA of for all other formats, too? + """ + + # Initialize counter and file decryptor + counter = Counter.new(64, prefix=nonce, initial_value=0) + decryptor = AES.new(key, AES.MODE_CTR, counter=counter) + + # Open and decrypt + with path_file_encrypted.open("rb") as f_src: + audio_decrypted = decryptor.decrypt(f_src.read()) + + # Replace with decrypted file + with path_file_destination.open("wb") as f_dst: + f_dst.write(audio_decrypted) diff --git a/tidal_dl_ng/helper/exceptions.py b/tidal_dl_ng/helper/exceptions.py new file mode 100644 index 0000000..be1bdee --- /dev/null +++ b/tidal_dl_ng/helper/exceptions.py @@ -0,0 +1,14 @@ +class LoginError(Exception): + pass + + +class MediaUnknown(Exception): + pass + + +class UnknownManifestFormat(Exception): + pass + + +class MediaMissing(Exception): + pass diff --git a/tidal_dl_ng/helper/gui.py b/tidal_dl_ng/helper/gui.py new file mode 100644 index 0000000..35a517f --- /dev/null +++ b/tidal_dl_ng/helper/gui.py @@ -0,0 +1,225 @@ +import re +from typing import cast + +from PySide6 import QtCore, QtGui, QtWidgets +from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video +from tidalapi.artist import Artist +from tidalapi.media import Quality +from tidalapi.playlist import Folder + +from tidal_dl_ng.constants import QualityVideo + + +def get_table_data( + item: QtWidgets.QTreeWidgetItem, column: int +) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist: + result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole) + + return result + + +def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str: + result: str = item.text(column) + + return result + + +def get_results_media_item( + index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel +) -> Track | Video | Album | Artist | Playlist | Mix: + # Switch column to "obj" column and map proxy data to our model. + item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1))) + result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole) + + return result + + +def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist | Folder | str: + result: Mix | Playlist | UserPlaylist | Folder | str = get_table_data(item, 1) + + return result + + +def get_queue_download_media( + item: QtWidgets.QTreeWidgetItem, +) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist: + result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1) + + return result + + +def get_queue_download_quality( + item: QtWidgets.QTreeWidgetItem, + column: int, +) -> str: + result: str = get_table_text(item, column) + + return result + + +def get_queue_download_quality_audio( + item: QtWidgets.QTreeWidgetItem, +) -> Quality: + result: Quality = cast(Quality, get_queue_download_quality(item, 4)) + + return result + + +def get_queue_download_quality_video( + item: QtWidgets.QTreeWidgetItem, +) -> QualityVideo: + result: QualityVideo = cast(QualityVideo, get_queue_download_quality(item, 5)) + + return result + + +def set_table_data( + item: QtWidgets.QTreeWidgetItem, + data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str, + column: int, +): + item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data) + + +def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist): + set_table_data(item, media, 1) + + +def set_user_list_media( + item: QtWidgets.QTreeWidgetItem, + media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist | Folder | str, +): + set_table_data(item, media, 1) + + +def set_queue_download_media( + item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist +): + set_table_data(item, media, 1) + + +class FilterHeader(QtWidgets.QHeaderView): + filter_activated = QtCore.Signal() + + def __init__(self, parent): + super().__init__(QtCore.Qt.Horizontal, parent) + self._editors = [] + self._padding = 4 + self.setCascadingSectionResizes(True) + self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) + self.setStretchLastSection(True) + self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) + self.setSortIndicatorShown(False) + self.setSectionsMovable(True) + self.sectionResized.connect(self.adjust_positions) + parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions) + + def set_filter_boxes(self, count): + while self._editors: + editor = self._editors.pop() + editor.deleteLater() + + for _ in range(count): + editor = QtWidgets.QLineEdit(self.parent()) + editor.setPlaceholderText("Filter") + editor.setClearButtonEnabled(True) + editor.returnPressed.connect(self.filter_activated.emit) + self._editors.append(editor) + + self.adjust_positions() + + def sizeHint(self): + size = super().sizeHint() + + if self._editors: + height = self._editors[0].sizeHint().height() + + size.setHeight(size.height() + height + self._padding) + + return size + + def updateGeometries(self): + if self._editors: + height = self._editors[0].sizeHint().height() + + self.setViewportMargins(0, 0, 0, height + self._padding) + else: + self.setViewportMargins(0, 0, 0, 0) + + super().updateGeometries() + self.adjust_positions() + + def adjust_positions(self): + for index, editor in enumerate(self._editors): + height = editor.sizeHint().height() + + editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2)) + editor.resize(self.sectionSize(index), height) + + def filter_text(self, index) -> str: + if 0 <= index < len(self._editors): + return self._editors[index].text() + + return "" + + def set_filter_text(self, index, text): + if 0 <= index < len(self._editors): + self._editors[index].setText(text) + + def clear_filters(self): + for editor in self._editors: + editor.clear() + + +class HumanProxyModel(QtCore.QSortFilterProxyModel): + def _human_key(self, key): + parts = re.split(r"(\d*\.\d+|\d+)", key) + + return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts)) + + def lessThan(self, source_left, source_right): + data_left = source_left.data() + data_right = source_right.data() + + if isinstance(data_left, str) and isinstance(data_right, str): + return self._human_key(data_left) < self._human_key(data_right) + + return super().lessThan(source_left, source_right) + + @property + def filters(self): + if not hasattr(self, "_filters"): + self._filters = [] + + return self._filters + + @filters.setter + def filters(self, filters): + self._filters = filters + + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool: + model = self.sourceModel() + source_index = model.index(source_row, 0, source_parent) + result: [bool] = [] + + # Show top level children + for child_row in range(model.rowCount(source_index)): + if self.filterAcceptsRow(child_row, source_index): + return True + + # Filter for actual needle + for i, text in self.filters: + if 0 <= i < self.columnCount(): + ix = self.sourceModel().index(source_row, i, source_parent) + data = ix.data() + + # Append results to list to enable an AND operator for filtering. + result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False) + + # If no filter set, just set the result to True. + if not result: + result.append(True) + + return all(result) diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py new file mode 100644 index 0000000..936ce79 --- /dev/null +++ b/tidal_dl_ng/helper/path.py @@ -0,0 +1,691 @@ +import math +import os +import pathlib +import posixpath +import re +import sys +from copy import deepcopy +from urllib.parse import unquote, urlsplit + +from pathvalidate import sanitize_filename, sanitize_filepath +from pathvalidate.error import ValidationError +from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video +from tidalapi.media import AudioExtensions + +from tidal_dl_ng import __name_display__ +from tidal_dl_ng.constants import ( + FILENAME_LENGTH_MAX, + FILENAME_SANITIZE_PLACEHOLDER, + FORMAT_TEMPLATE_EXPLICIT, + UNIQUIFY_THRESHOLD, + MediaType, +) +from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title + + +def path_home() -> str: + """Get the home directory path. + + Returns: + str: The home directory path. + """ + if "XDG_CONFIG_HOME" in os.environ: + return os.environ["XDG_CONFIG_HOME"] + elif "HOME" in os.environ: + return os.environ["HOME"] + elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: + return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"]) + else: + return os.path.abspath("./") + + +def path_config_base() -> str: + """Get the base configuration path. + + Returns: + str: The base configuration path. + """ + # https://wiki.archlinux.org/title/XDG_Base_Directory + # X11 workaround: If user specified config path is set, do not point to "~/.config" + path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "") + path_config: str = ".config" if not path_user_custom else "" + path_base: str = os.path.join(path_home(), path_config, __name_display__) + + return path_base + + +def path_file_log() -> str: + """Get the path to the log file. + + Returns: + str: The log file path. + """ + return os.path.join(path_config_base(), "app.log") + + +def path_file_token() -> str: + """Get the path to the token file. + + Returns: + str: The token file path. + """ + return os.path.join(path_config_base(), "token.json") + + +def path_file_settings() -> str: + """Get the path to the settings file. + + Returns: + str: The settings file path. + """ + return os.path.join(path_config_base(), "settings.json") + + +def format_path_media( + fmt_template: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + album_track_num_pad_min: int = 0, + list_pos: int = 0, + list_total: int = 0, + delimiter_artist: str = ", ", + delimiter_album_artist: str = ", ", + use_primary_album_artist: bool = False, +) -> str: + """Formats a media path string using a template and media attributes. + + Replaces placeholders in the format template with sanitized media attribute values to generate a valid file path. + + Args: + fmt_template (str): The format template string containing placeholders. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from. + album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0. + list_pos (int, optional): Position in a list. Defaults to 0. + list_total (int, optional): Total items in a list. Defaults to 0. + delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". + delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". + use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. + + Returns: + str: The formatted and sanitized media path string. + """ + result = fmt_template + + # Search track format template for placeholder. + regex = r"\{(.+?)\}" + matches = re.finditer(regex, fmt_template, re.MULTILINE) + + for _matchNum, match in enumerate(matches, start=1): + template_str = match.group() + result_fmt = format_str_media( + match.group(1), + media, + album_track_num_pad_min, + list_pos, + list_total, + delimiter_artist=delimiter_artist, + delimiter_album_artist=delimiter_album_artist, + use_primary_album_artist=use_primary_album_artist, + ) + + if result_fmt != match.group(1): + # Sanitize here, in case of the filename has slashes or something, which will be recognized later as a directory separator. + # Do not sanitize if value is the FORMAT_TEMPLATE_EXPLICIT placeholder, since it has a leading whitespace which otherwise gets removed. + value = ( + sanitize_filename(result_fmt) if result_fmt != FORMAT_TEMPLATE_EXPLICIT else FORMAT_TEMPLATE_EXPLICIT + ) + result = result.replace(template_str, value) + + return result + + +def format_str_media( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + album_track_num_pad_min: int = 0, + list_pos: int = 0, + list_total: int = 0, + delimiter_artist: str = ", ", + delimiter_album_artist: str = ", ", + use_primary_album_artist: bool = False, +) -> str: + """Formats a string for media attributes based on the provided name. + + Attempts to format the given name using a sequence of formatter functions, returning the first successful result. + + Args: + name (str): The format string name to process. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract values from. + album_track_num_pad_min (int, optional): Minimum padding for track numbers. Defaults to 0. + list_pos (int, optional): Position in a list. Defaults to 0. + list_total (int, optional): Total items in a list. Defaults to 0. + delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". + delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". + use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. + + Returns: + str: The formatted string for the media attribute, or the original name if no formatter matches. + """ + try: + # Try each formatter function in sequence + for formatter in ( + _format_names, + _format_numbers, + _format_ids, + _format_durations, + _format_dates, + _format_metadata, + _format_volumes, + ): + result = formatter( + name, + media, + album_track_num_pad_min, + list_pos, + list_total, + delimiter_artist=delimiter_artist, + delimiter_album_artist=delimiter_album_artist, + use_primary_album_artist=use_primary_album_artist, + ) + if result is not None: + return result + except (AttributeError, KeyError, TypeError, ValueError) as e: + print(f"Error formatting path for media attribute '{name}': {e}") + + return name + + +def _format_artist_names( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + delimiter_artist: str = ", ", + delimiter_album_artist: str = ", ", + *_args, + use_primary_album_artist: bool = False, + **kwargs, +) -> str | None: + """Handle artist name-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract artist information from. + delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". + delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". + use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted artist name or None if the format string is not artist-related. + """ + if name == "artist_name" and isinstance(media, Track | Video): + # For folder paths, use album artist if setting is enabled + if use_primary_album_artist and hasattr(media, "album") and media.album and media.album.artists: + return media.album.artists[0].name + # Otherwise use track artists as before + if hasattr(media, "artists"): + return name_builder_artist(media, delimiter=delimiter_artist) + elif hasattr(media, "artist"): + return media.artist.name + elif name == "album_artist": + return name_builder_album_artist(media, first_only=True) + elif name == "album_artists": + return name_builder_album_artist(media, delimiter=delimiter_album_artist) + return None + + +def _format_titles( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle title-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract title information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted title or None if the format string is not title-related. + """ + if name == "track_title" and isinstance(media, Track | Video): + return name_builder_title(media) + elif name == "mix_name" and isinstance(media, Mix): + return media.title + elif name == "playlist_name" and isinstance(media, Playlist | UserPlaylist): + return media.name + elif name == "album_title": + if isinstance(media, Album): + return media.name + elif isinstance(media, Track): + return media.album.name + return None + + +def _format_names( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + *args, + delimiter_artist: str = ", ", + delimiter_album_artist: str = ", ", + use_primary_album_artist: bool = False, + **kwargs, +) -> str | None: + """Handles name-related format strings for media. + + Tries to format the provided name as an artist or title, returning the first matching result. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract name information from. + *args: Additional arguments (not used). + delimiter_artist (str, optional): Delimiter for artist names. Defaults to ", ". + delimiter_album_artist (str, optional): Delimiter for album artist names. Defaults to ", ". + use_primary_album_artist (bool, optional): If True, uses first album artist for folder paths. Defaults to False. + + Returns: + str | None: The formatted name or None if the format string is not name-related. + """ + # First try artist name formats + result = _format_artist_names( + name, + media, + delimiter_artist=delimiter_artist, + delimiter_album_artist=delimiter_album_artist, + use_primary_album_artist=use_primary_album_artist, + ) + if result is not None: + return result + + # Then try title formats + return _format_titles(name, media) + + +def _format_numbers( + name: str, + media: Track | Album | Playlist | UserPlaylist | Video | Mix, + album_track_num_pad_min: int, + list_pos: int, + list_total: int, + *_args, + **kwargs, +) -> str | None: + """Handle number-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract number information from. + album_track_num_pad_min (int): Minimum padding for track numbers. + list_pos (int): Position in a list. + list_total (int): Total items in a list. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted number or None if the format string is not number-related. + """ + if name == "album_track_num" and isinstance(media, Track | Video): + return calculate_number_padding( + album_track_num_pad_min, + media.track_num, + media.album.num_tracks if hasattr(media, "album") else 1, + ) + elif name == "album_num_tracks" and isinstance(media, Track | Video): + return str(media.album.num_tracks if hasattr(media, "album") else 1) + elif name == "list_pos" and isinstance(media, Track | Video): + # TODO: Rename `album_track_num_pad_min` globally. + return calculate_number_padding(album_track_num_pad_min, list_pos, list_total) + return None + + +def _format_ids( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle ID-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract ID information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted ID or None if the format string is not ID-related. + """ + # Handle track and playlist IDs + if ( + (name == "track_id" and isinstance(media, Track)) + or (name == "playlist_id" and isinstance(media, Playlist)) + or (name == "video_id" and isinstance(media, Video)) + ): + return str(media.id) + # Handle album IDs + elif name == "album_id": + if isinstance(media, Album): + return str(media.id) + elif isinstance(media, Track): + return str(media.album.id) + # Handle ISRC + elif name == "isrc" and isinstance(media, Track): + return media.isrc + elif name == "album_artist_id" and isinstance(media, Album): + return str(media.artist.id) + elif name == "track_artist_id" and isinstance(media, Track): + return str(media.album.artist.id) + return None + + +def _format_durations( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle duration-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract duration information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted duration or None if the format string is not duration-related. + """ + # Format track durations + if name == "track_duration_seconds" and isinstance(media, Track | Video): + return str(media.duration) + elif name == "track_duration_minutes" and isinstance(media, Track | Video): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + # Format album durations + elif name == "album_duration_seconds" and isinstance(media, Album): + return str(media.duration) + elif name == "album_duration_minutes" and isinstance(media, Album): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + # Format playlist durations + elif name == "playlist_duration_seconds" and isinstance(media, Album): + return str(media.duration) + elif name == "playlist_duration_minutes" and isinstance(media, Album): + m, s = divmod(media.duration, 60) + return f"{m:01d}:{s:02d}" + + return None + + +def _format_dates( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle date-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract date information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted date or None if the format string is not date-related. + """ + if name == "album_year": + if isinstance(media, Album): + return str(media.year) + elif isinstance(media, Track): + return str(media.album.year) + elif name == "album_date": + if isinstance(media, Album): + return media.release_date.strftime("%Y-%m-%d") if media.release_date else None + elif isinstance(media, Track): + return media.album.release_date.strftime("%Y-%m-%d") if media.album.release_date else None + + return None + + +def _format_metadata( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle metadata-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract metadata information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted metadata or None if the format string is not metadata-related. + """ + if name == "video_quality" and isinstance(media, Video): + return media.video_quality + elif name == "track_quality" and isinstance(media, Track): + return ", ".join(tag for tag in media.media_metadata_tags if tag is not None) + elif (name == "track_explicit" and isinstance(media, Track | Video)) or ( + name == "album_explicit" and isinstance(media, Album) + ): + return FORMAT_TEMPLATE_EXPLICIT if media.explicit else "" + elif name == "media_type": + if isinstance(media, Album): + return media.type + elif isinstance(media, Track): + return media.album.type + return None + + +def _format_volumes( + name: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix, *_args, **kwargs +) -> str | None: + """Handle volume-related format strings. + + Args: + name (str): The format string name to check. + media (Track | Album | Playlist | UserPlaylist | Video | Mix): The media object to extract volume information from. + *_args (Any): Additional arguments (not used). + + Returns: + str | None: The formatted volume information or None if the format string is not volume-related. + """ + if name == "album_num_volumes" and isinstance(media, Album): + return str(media.num_volumes) + elif name == "track_volume_num" and isinstance(media, Track | Video): + return str(media.volume_num) + elif name == "track_volume_num_optional" and isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + return "" if num_volumes == 1 else str(media.volume_num) + elif name == "track_volume_num_optional_CD" and isinstance(media, Track | Video): + num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 + return "" if num_volumes == 1 else f"CD{media.volume_num!s}" + return None + + +def calculate_number_padding(padding_minimum: int, item_position: int, items_max: int) -> str: + """Calculate the padded number string for an item. + + Args: + padding_minimum (int): Minimum number of digits for padding. + item_position (int): The position of the item. + items_max (int): The maximum number of items. + + Returns: + str: The padded number string. + """ + result: str + + if items_max > 0: + count_digits = max(int(math.log10(items_max)) + 1, padding_minimum) + result = str(item_position).zfill(count_digits) + else: + result = str(item_position) + + return result + + +def get_format_template( + media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings +) -> str | bool: + """Get the format template for a given media type. + + Args: + media (Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType): The media object or type. + settings: The settings object containing format templates. + + Returns: + str | bool: The format template string or False if not found. + """ + result = False + + if isinstance(media, Track) or media == MediaType.TRACK: + result = settings.data.format_track + elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST: + result = settings.data.format_album + elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST: + result = settings.data.format_playlist + elif isinstance(media, Mix) or media == MediaType.MIX: + result = settings.data.format_mix + elif isinstance(media, Video) or media == MediaType.VIDEO: + result = settings.data.format_video + + return result + + +def path_file_sanitize(path_file: pathlib.Path, adapt: bool = False, uniquify: bool = False) -> pathlib.Path: + """Sanitize a file path to ensure it is valid and optionally make it unique. + + Args: + path_file (pathlib.Path): The file path to sanitize. + adapt (bool, optional): Whether to adapt the path in case of errors. Defaults to False. + uniquify (bool, optional): Whether to make the file name unique. Defaults to False. + + Returns: + pathlib.Path: The sanitized file path. + """ + sanitized_filename = sanitize_filename( + path_file.name, replacement_text="_", validate_after_sanitize=True, platform="auto" + ) + + if not sanitized_filename.endswith(path_file.suffix): + sanitized_filename = ( + sanitized_filename[: -len(path_file.suffix) - len(FILENAME_SANITIZE_PLACEHOLDER)] + + FILENAME_SANITIZE_PLACEHOLDER + + path_file.suffix + ) + + sanitized_path = pathlib.Path( + *[ + ( + sanitize_filename(part, replacement_text="_", validate_after_sanitize=True, platform="auto") + if part not in path_file.anchor + else part + ) + for part in path_file.parent.parts + ] + ) + + try: + sanitized_path = sanitize_filepath( + sanitized_path, replacement_text="_", validate_after_sanitize=True, platform="auto" + ) + except ValidationError as e: + if adapt and str(e).startswith("[PV1101]"): + sanitized_path = pathlib.Path.home() + else: + raise + + result = sanitized_path / sanitized_filename + + return path_file_uniquify(result) if uniquify else result + + +def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: + """Ensure a file path is unique by appending a suffix if necessary. + + Args: + path_file (pathlib.Path): The file path to uniquify. + + Returns: + pathlib.Path: The unique file path. + """ + unique_suffix: str = file_unique_suffix(path_file) + + if unique_suffix: + file_suffix = unique_suffix + path_file.suffix + # For most OS filename has a character limit of 255. + path_file = ( + path_file.parent / (str(path_file.stem)[: -len(file_suffix)] + file_suffix) + if len(str(path_file.parent / (path_file.stem + unique_suffix))) > FILENAME_LENGTH_MAX + else path_file.parent / (path_file.stem + unique_suffix) + ) + + return path_file + + +def file_unique_suffix(path_file: pathlib.Path, separator: str = "_") -> str: + """Generate a unique suffix for a file path. + + Args: + path_file (pathlib.Path): The file path to check for uniqueness. + separator (str, optional): The separator to use for the suffix. Defaults to "_". + + Returns: + str: The unique suffix, or an empty string if not needed. + """ + threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD)) + count: int = 0 + path_file_tmp: pathlib.Path = deepcopy(path_file) + unique_suffix: str = "" + + while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD: + count += 1 + unique_suffix = separator + str(count).zfill(threshold_zfill) + path_file_tmp = path_file.parent / (path_file.stem + unique_suffix + path_file.suffix) + + return unique_suffix + + +def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool: + """Check if a file exists. + + Args: + path_file (pathlib.Path): The file path to check. + extension_ignore (bool, optional): Whether to ignore the file extension. Defaults to False. + + Returns: + bool: True if the file exists, False otherwise. + """ + if extension_ignore: + path_file_stem: str = pathlib.Path(path_file).stem + path_parent: pathlib.Path = pathlib.Path(path_file).parent + path_files: list[str] = [] + + path_files.extend(str(path_parent.joinpath(path_file_stem + extension)) for extension in AudioExtensions) + else: + path_files: list[str] = [str(path_file)] + + return any(os.path.isfile(_file) for _file in path_files) + + +def resource_path(relative_path: str) -> str: + """Get the absolute path to a resource. + + Args: + relative_path: The relative path to the resource. + + Returns: + str: The absolute path to the resource. + """ + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) + + return os.path.join(base_path, relative_path) + + +def url_to_filename(url: str) -> str: + """Convert a URL to a valid filename. + + Args: + url (str): The URL to convert. + + Returns: + str: The corresponding filename. + + Raises: + ValueError: If the URL contains invalid characters for a filename. + """ + urlpath: str = urlsplit(url).path + basename: str = posixpath.basename(unquote(urlpath)) + + if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename: + raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows + + return basename diff --git a/tidal_dl_ng/helper/tidal.py b/tidal_dl_ng/helper/tidal.py new file mode 100644 index 0000000..586e196 --- /dev/null +++ b/tidal_dl_ng/helper/tidal.py @@ -0,0 +1,274 @@ +from collections.abc import Callable + +from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video +from tidalapi.artist import Artist, Role +from tidalapi.media import MediaMetadataTags, Quality +from tidalapi.session import SearchTypes +from tidalapi.user import LoggedInUser + +from tidal_dl_ng.constants import FAVORITES, MediaType +from tidal_dl_ng.helper.exceptions import MediaUnknown + + +def name_builder_artist(media: Track | Video | Album, delimiter: str = ", ") -> str: + """Builds a string of artist names for a track, video, or album. + + Returns a delimited string of all artist names associated with the given media. + + Args: + media (Track | Video | Album): The media object to extract artist names from. + delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ". + + Returns: + str: A delimited string of artist names. + """ + return delimiter.join(artist.name for artist in media.artists) + + +def name_builder_album_artist(media: Track | Album, first_only: bool = False, delimiter: str = ", ") -> str: + """Builds a string of main album artist names for a track or album. + + Returns a delimited string of main artist names from the album, optionally including only the first main artist. + + Args: + media (Track | Album): The media object to extract artist names from. + first_only (bool, optional): If True, only the first main artist is included. Defaults to False. + delimiter (str, optional): The delimiter to use between artist names. Defaults to ", ". + + Returns: + str: A delimited string of main album artist names. + """ + artists_tmp: [str] = [] + artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists + + for artist in artists: + if Role.main in artist.roles: + artists_tmp.append(artist.name) + + if first_only: + break + + return delimiter.join(artists_tmp) + + +def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str: + result: str = ( + media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name + ) + + return result + + +def name_builder_item(media: Track | Video) -> str: + return f"{name_builder_artist(media)} - {name_builder_title(media)}" + + +def get_tidal_media_id(url_or_id_media: str) -> str: + + id_dirty = url_or_id_media.rsplit("/", 1)[-1] + id_media = id_dirty.rsplit("?", 1)[0] + + return id_media + + +def get_tidal_media_type(url_media: str) -> MediaType | bool: + result: MediaType | bool = False + url_split = url_media.split("/")[-2] + + if len(url_split) > 1: + media_name = url_media.split("/")[-2] + + if media_name == "track": + result = MediaType.TRACK + elif media_name == "video": + result = MediaType.VIDEO + elif media_name == "album": + result = MediaType.ALBUM + elif media_name == "playlist": + result = MediaType.PLAYLIST + elif media_name == "mix": + result = MediaType.MIX + elif media_name == "artist": + result = MediaType.ARTIST + + return result + + +def url_ending_clean(url: str) -> str: + """Checks if a link ends with "/u" or "?u" and removes that part. + + Args: + url (str): The URL to clean. + + Returns: + str: The cleaned URL. + """ + return url[:-2] if url.endswith("/u") or url.endswith("?u") else url + + +def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]: + limit: int = 300 + offset: int = 0 + done: bool = False + result: dict[str, [SearchTypes]] = {} + + while not done: + tmp_result: dict[str, [SearchTypes]] = session.search( + query=needle, models=types_media, limit=limit, offset=offset + ) + tmp_done: bool = True + + for key, value in tmp_result.items(): + # Append pagination results, if there are any + if offset == 0: + result = tmp_result + tmp_done = False + elif bool(value): + result[key] += value + tmp_done = False + + # Next page + offset += limit + done = tmp_done + + return result + + +def items_results_all( + media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True +) -> [Track | Video | Album]: + result: [Track | Video | Album] = [] + + if isinstance(media_list, Mix): + result = media_list.items() + else: + func_get_items_media: [Callable] = [] + + if isinstance(media_list, Playlist | Album): + if videos_include: + func_get_items_media.append(media_list.items) + else: + func_get_items_media.append(media_list.tracks) + else: + func_get_items_media.append(media_list.get_albums) + func_get_items_media.append(media_list.get_ep_singles) + + result = paginate_results(func_get_items_media) + + return result + + +def all_artist_album_ids(media_artist: Artist) -> [int | None]: + result: [int] = [] + func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles] + albums: [Album] = paginate_results(func_get_items_media) + + for album in albums: + result.append(album.id) + + return result + + +def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]: + result: [Track | Video | Album] = [] + + for func_media in func_get_items_media: + limit: int = 100 + offset: int = 0 + done: bool = False + + if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists: + limit: int = 50 + + while not done: + tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset) + + if bool(tmp_result): + result += tmp_result + # Get the next page in the next iteration. + offset += limit + else: + done = True + + return result + + +def user_media_lists(session: Session) -> dict[str, list]: + """Fetch user media lists using tidalapi's built-in pagination where available. + + Returns a dictionary with 'playlists' and 'mixes' keys containing lists of media items. + For playlists, includes both Folder and Playlist objects at the root level. + + Args: + session (Session): TIDAL session object. + + Returns: + dict[str, list]: Dictionary with 'playlists' (includes Folder and Playlist) and 'mixes' lists. + """ + # Use built-in pagination for playlists (root level only) + playlists = session.user.favorites.playlists_paginated() + + # Fetch root-level folders manually (no paginated version available) + folders = [] + offset = 0 + limit = 50 + + while True: + batch = session.user.favorites.playlist_folders(limit=limit, offset=offset, parent_folder_id="root") + if not batch: + break + folders.extend(batch) + if len(batch) < limit: + break + offset += limit + + # Combine folders and playlists + all_playlists = folders + playlists + + # Get mixes + user_mixes = session.mixes().categories[0].items + + return {"playlists": all_playlists, "mixes": user_mixes} + + +def instantiate_media( + session: Session, + media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX], + id_media: str, +) -> Track | Video | Album | Playlist | Mix | Artist: + if media_type == MediaType.TRACK: + media = session.track(id_media, with_album=True) + elif media_type == MediaType.VIDEO: + media = session.video(id_media) + elif media_type == MediaType.ALBUM: + media = session.album(id_media) + elif media_type == MediaType.PLAYLIST: + media = session.playlist(id_media) + elif media_type == MediaType.MIX: + media = session.mix(id_media) + elif media_type == MediaType.ARTIST: + media = session.artist(id_media) + else: + raise MediaUnknown + + return media + + +def quality_audio_highest(media: Track | Album) -> Quality: + quality: Quality + + if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags: + quality = Quality.hi_res_lossless + elif MediaMetadataTags.lossless in media.media_metadata_tags: + quality = Quality.high_lossless + else: + quality = media.audio_quality + + return quality + + +def favorite_function_factory(tidal, favorite_item: str): + function_name: str = FAVORITES[favorite_item]["function_name"] + function_list: Callable = getattr(tidal.session.user.favorites, function_name) + + return function_list diff --git a/tidal_dl_ng/helper/wrapper.py b/tidal_dl_ng/helper/wrapper.py new file mode 100644 index 0000000..cd8e6ba --- /dev/null +++ b/tidal_dl_ng/helper/wrapper.py @@ -0,0 +1,26 @@ +from collections.abc import Callable + + +class LoggerWrapped: + fn_print: Callable = None + + def __init__(self, fn_print: Callable): + self.fn_print = fn_print + + def debug(self, value): + self.fn_print(value) + + def warning(self, value): + self.fn_print(value) + + def info(self, value): + self.fn_print(value) + + def error(self, value): + self.fn_print(value) + + def critical(self, value): + self.fn_print(value) + + def exception(self, value): + self.fn_print(value) diff --git a/tidal_dl_ng/logger.py b/tidal_dl_ng/logger.py new file mode 100644 index 0000000..83477a7 --- /dev/null +++ b/tidal_dl_ng/logger.py @@ -0,0 +1,65 @@ +import logging +import sys + +import coloredlogs +from PySide6 import QtCore + + +class XStream(QtCore.QObject): + _stdout = None + _stderr = None + messageWritten = QtCore.Signal(str) + + def flush(self): + pass + + def fileno(self): + return -1 + + def write(self, msg): + if not self.signalsBlocked(): + self.messageWritten.emit(msg) + + @staticmethod + def stdout(): + if not XStream._stdout: + XStream._stdout = XStream() + sys.stdout = XStream._stdout + return XStream._stdout + + @staticmethod + def stderr(): + if not XStream._stderr: + XStream._stderr = XStream() + sys.stderr = XStream._stderr + return XStream._stderr + + +class QtHandler(logging.Handler): + def __init__(self): + logging.Handler.__init__(self) + + def emit(self, record): + record = self.format(record) + + if record: + # originally: XStream.stdout().write("{}\n".format(record)) + XStream.stdout().write("%s\n" % record) + + +logger_gui = logging.getLogger(__name__) +handler_qt: QtHandler = QtHandler() +# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s" +log_fmt: str = "> %(message)s" +# formatter = logging.Formatter(log_fmt) +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +handler_qt.setFormatter(formatter) +logger_gui.addHandler(handler_qt) +logger_gui.setLevel(logging.DEBUG) + +logger_cli = logging.getLogger(__name__) +handler_stream: logging.StreamHandler = logging.StreamHandler() +formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) +handler_stream.setFormatter(formatter) +logger_cli.addHandler(handler_stream) +logger_cli.setLevel(logging.DEBUG) diff --git a/tidal_dl_ng/metadata.py b/tidal_dl_ng/metadata.py new file mode 100644 index 0000000..b3a7ef8 --- /dev/null +++ b/tidal_dl_ng/metadata.py @@ -0,0 +1,206 @@ +import pathlib + +import mutagen +from mutagen import flac, id3, mp4 +from mutagen.id3 import APIC, SYLT, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, TXXX, USLT, WOAS + + +class Metadata: + path_file: str | pathlib.Path + title: str + album: str + albumartist: str + artists: str + copy_right: str + tracknumber: int + discnumber: int + totaldisc: int + totaltrack: int + date: str + composer: str + isrc: str + lyrics: str + lyrics_unsynced: str + path_cover: str + cover_data: bytes + album_replay_gain: float + album_peak_amplitude: float + track_replay_gain: float + track_peak_amplitude: float + url_share: str + replay_gain_write: bool + upc: str + target_upc: dict[str, str] + explicit: bool + m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC + + def __init__( + self, + path_file: str | pathlib.Path, + target_upc: dict[str, str], + album: str = "", + title: str = "", + artists: str = "", + copy_right: str = "", + tracknumber: int = 0, + discnumber: int = 0, + totaltrack: int = 0, + totaldisc: int = 0, + composer: str = "", + isrc: str = "", + albumartist: str = "", + date: str = "", + lyrics: str = "", + lyrics_unsynced: str = "", + cover_data: bytes = None, + album_replay_gain: float = 1.0, + album_peak_amplitude: float = 1.0, + track_replay_gain: float = 1.0, + track_peak_amplitude: float = 1.0, + url_share: str = "", + replay_gain_write: bool = True, + upc: str = "", + explicit: bool = False, + ): + self.path_file = path_file + self.title = title + self.album = album + self.albumartist = albumartist + self.artists = artists + self.copy_right = copy_right + self.tracknumber = tracknumber + self.discnumber = discnumber + self.totaldisc = totaldisc + self.totaltrack = totaltrack + self.date = date + self.composer = composer + self.isrc = isrc + self.lyrics = lyrics + self.lyrics_unsynced = lyrics_unsynced + self.cover_data = cover_data + self.album_replay_gain = album_replay_gain + self.album_peak_amplitude = album_peak_amplitude + self.track_replay_gain = track_replay_gain + self.track_peak_amplitude = track_peak_amplitude + self.url_share = url_share + self.replay_gain_write = replay_gain_write + self.upc = upc + self.target_upc = target_upc + self.explicit = explicit + self.m: mutagen.FileType = mutagen.File(self.path_file) + + def _cover(self) -> bool: + result: bool = False + + if self.cover_data: + if isinstance(self.m, mutagen.flac.FLAC): + flac_cover = flac.Picture() + flac_cover.type = id3.PictureType.COVER_FRONT + flac_cover.data = self.cover_data + flac_cover.mime = "image/jpeg" + + self.m.clear_pictures() + self.m.add_picture(flac_cover) + elif isinstance(self.m, mutagen.mp3.MP3): + self.m.tags.add(APIC(encoding=3, data=self.cover_data)) + elif isinstance(self.m, mutagen.mp4.MP4): + cover_mp4 = mp4.MP4Cover(self.cover_data) + self.m.tags["covr"] = [cover_mp4] + + result = True + + return result + + def save(self): + if not self.m.tags: + self.m.add_tags() + + if isinstance(self.m, mutagen.flac.FLAC): + self.set_flac() + elif isinstance(self.m, mutagen.mp3.MP3): + self.set_mp3() + elif isinstance(self.m, mutagen.mp4.MP4): + self.set_mp4() + + self._cover() + self.cleanup_tags() + self.m.save() + + return True + + def set_flac(self): + self.m.tags["TITLE"] = self.title + self.m.tags["ALBUM"] = self.album + self.m.tags["ALBUMARTIST"] = self.albumartist + self.m.tags["ARTIST"] = self.artists + self.m.tags["COPYRIGHT"] = self.copy_right + self.m.tags["TRACKNUMBER"] = str(self.tracknumber) + self.m.tags["TRACKTOTAL"] = str(self.totaltrack) + self.m.tags["DISCNUMBER"] = str(self.discnumber) + self.m.tags["DISCTOTAL"] = str(self.totaldisc) + self.m.tags["DATE"] = self.date + self.m.tags["COMPOSER"] = self.composer + self.m.tags["ISRC"] = self.isrc + self.m.tags["LYRICS"] = self.lyrics + self.m.tags["UNSYNCEDLYRICS"] = self.lyrics_unsynced + self.m.tags["URL"] = self.url_share + self.m.tags[self.target_upc["FLAC"]] = self.upc + + if self.replay_gain_write: + self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain) + self.m.tags["REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude) + self.m.tags["REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain) + self.m.tags["REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude) + + def set_mp3(self): + # ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0 + # Mapping overview: https://docs.mp3tag.de/mapping/ + self.m.tags.add(TIT2(encoding=3, text=self.title)) + self.m.tags.add(TALB(encoding=3, text=self.album)) + self.m.tags.add(TOPE(encoding=3, text=self.albumartist)) + self.m.tags.add(TPE1(encoding=3, text=self.artists)) + self.m.tags.add(TCOP(encoding=3, text=self.copy_right)) + self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber))) + self.m.tags.add(TDRC(encoding=3, text=self.date)) + self.m.tags.add(TCOM(encoding=3, text=self.composer)) + 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(USLT(encoding=3, desc="text", text=self.lyrics_unsynced)) + 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)) + + 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_PEAK", text=str(self.album_peak_amplitude))) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=str(self.track_replay_gain))) + self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_PEAK", text=str(self.track_peak_amplitude))) + + def set_mp4(self): + self.m.tags["\xa9nam"] = self.title + self.m.tags["\xa9alb"] = self.album + self.m.tags["aART"] = self.albumartist + self.m.tags["\xa9ART"] = self.artists + self.m.tags["cprt"] = self.copy_right + self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]] + self.m.tags["disk"] = [[self.discnumber, self.totaldisc]] + # self.m.tags['\xa9gen'] = self.genre + self.m.tags["\xa9day"] = self.date + self.m.tags["\xa9wrt"] = self.composer + self.m.tags["\xa9lyr"] = self.lyrics + self.m.tags["----:com.apple.iTunes:UNSYNCEDLYRICS"] = self.lyrics_unsynced.encode("utf-8") + self.m.tags["isrc"] = self.isrc + 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["rtng"] = [1 if self.explicit else 0] + + 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_PEAK"] = str(self.album_peak_amplitude).encode("utf-8") + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain).encode("utf-8") + self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude).encode("utf-8") + + def cleanup_tags(self): + # Collect keys to delete first to avoid RuntimeError during iteration + keys_to_delete = [key for key, value in self.m.tags.items() if value == "" or value == [""]] + for key in keys_to_delete: + del self.m.tags[key] diff --git a/tidal_dl_ng/model/__init__.py b/tidal_dl_ng/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py new file mode 100644 index 0000000..bfca708 --- /dev/null +++ b/tidal_dl_ng/model/cfg.py @@ -0,0 +1,145 @@ +from dataclasses import dataclass + +from dataclasses_json import dataclass_json +from tidalapi import Quality + +from tidal_dl_ng.constants import CoverDimensions, MetadataTargetUPC, QualityVideo + + +@dataclass_json +@dataclass +class Settings: + skip_existing: bool = True + lyrics_embed: bool = False + lyrics_file: bool = False + use_primary_album_artist: bool = ( + False # When True, uses first album artist instead of track artists for folder paths + ) + # TODO: Implement API KEY selection. + # api_key_index: bool = 0 + # TODO: Implement album info download to separate file. + # album_info_save: bool = False + video_download: bool = True + # TODO: Implement multi threading for downloads. + # multi_thread: bool = False + download_delay: bool = True + download_base_path: str = "~/download" + quality_audio: Quality = Quality.low_320k + quality_video: QualityVideo = QualityVideo.P480 + download_dolby_atmos: bool = False + format_album: str = ( + "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" + "{album_track_num}. {artist_name} - {track_title}{album_explicit}" + ) + format_playlist: str = "Playlists/{playlist_name}/{list_pos}. {artist_name} - {track_title}" + format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}" + format_track: str = "Tracks/{artist_name} - {track_title}{track_explicit}" + format_video: str = "Videos/{artist_name} - {track_title}{track_explicit}" + video_convert_mp4: bool = True + path_binary_ffmpeg: str = "" + metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320 + metadata_cover_embed: bool = True + mark_explicit: bool = False + cover_album_file: bool = True + extract_flac: bool = True + downloads_simultaneous_per_track_max: int = 20 + download_delay_sec_min: float = 3.0 + download_delay_sec_max: float = 5.0 + album_track_num_pad_min: int = 1 + downloads_concurrent_max: int = 3 + symlink_to_track: bool = False + playlist_create: bool = False + metadata_replay_gain: bool = False + metadata_write_url: bool = True + window_x: int = 50 + window_y: int = 50 + window_w: int = 1200 + window_h: int = 800 + metadata_delimiter_artist: str = ", " + metadata_delimiter_album_artist: str = ", " + filename_delimiter_artist: str = ", " + filename_delimiter_album_artist: str = ", " + metadata_target_upc: MetadataTargetUPC = MetadataTargetUPC.UPC + # Rate limiting for API calls (tweaking variables) + api_rate_limit_batch_size: int = 20 # Number of albums to process before applying rate limit delay + api_rate_limit_delay_sec: float = 3.0 # Delay in seconds between batches to avoid rate limiting + + +@dataclass_json +@dataclass +class HelpSettings: + skip_existing: str = "Skip download if file already exists." + album_cover_save: str = "Save cover to album folder." + lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available." + use_primary_album_artist: str = "Use only the primary album artist for folder paths instead of track artists." + lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available." + api_key_index: str = "Set the device API KEY." + album_info_save: str = "Save album info to track?" + video_download: str = "Allow download of videos." + multi_thread: str = "Download several tracks in parallel." + download_delay: str = "Activate randomized download delay to mimic human behaviour." + download_base_path: str = "Where to store the downloaded media." + quality_audio: str = ( + 'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), ' + '"LOSSLESS" (16 Bit, 44,1 kHz), ' + '"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)' + ) + quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' + download_dolby_atmos: str = "Download Dolby Atmos audio streams if available." + # TODO: Describe possible variables. + format_album: str = "Where to download albums and how to name the items." + format_playlist: str = "Where to download playlists and how to name the items." + format_mix: str = "Where to download mixes and how to name the items." + format_track: str = "Where to download tracks and how to name the items." + format_video: str = "Where to download videos and how to name the items." + video_convert_mp4: str = ( + "Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video " + "will be converted to MP4. FFmpeg must be installed." + ) + path_binary_ffmpeg: str = ( + "Path to FFmpeg binary file (executable). Only necessary if FFmpeg is not set in $PATH. Mandatory for Windows: " + "The directory of `ffmpeg.exe` must be set in %PATH%." + ) + metadata_cover_dimension: str = ( + "The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640, 1280x1280." + ) + metadata_cover_embed: str = "Embed album cover into file." + mark_explicit: str = "Mark explicit tracks with '🅴' in track title (only applies to metadata)." + cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded." + extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)." + downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track." + download_delay_sec_min: str = "Lower boundary for the calculation of the download delay in seconds." + download_delay_sec_max: str = "Upper boundary for the calculation of the download delay in seconds." + album_track_num_pad_min: str = ( + "Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1." + ) + downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)." + symlink_to_track: str = ( + "If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked " + "accordingly." + ) + playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes." + metadata_replay_gain: str = "Replay gain information will be written to metadata." + metadata_write_url: str = "URL of the media file will be written to metadata." + window_x: str = "X-Coordinate of saved window location." + window_y: str = "Y-Coordinate of saved window location." + window_w: str = "Width of saved window size." + window_h: str = "Height of saved window size." + metadata_delimiter_artist: str = "Metadata tag delimiter for multiple artists. Default: ', '" + metadata_delimiter_album_artist: str = "Metadata tag delimiter for multiple album artists. Default: ', '" + filename_delimiter_artist: str = "Filename delimiter for multiple artists. Default: ', '" + filename_delimiter_album_artist: str = "Filename delimiter for multiple album artists. Default: ', '" + metadata_target_upc: str = ( + "Select the target metadata tag ('UPC', 'BARCODE', 'EAN') where to write the UPC information to. Default: 'UPC'." + ) + api_rate_limit_batch_size: str = "Number of albums to process before applying rate limit delay (tweaking variable)." + api_rate_limit_delay_sec: str = "Delay in seconds between batches to avoid API rate limiting (tweaking variable)." + + +@dataclass_json +@dataclass +class Token: + token_type: str | None = None + access_token: str | None = None + refresh_token: str | None = None + expiry_time: float = 0.0 diff --git a/tidal_dl_ng/model/downloader.py b/tidal_dl_ng/model/downloader.py new file mode 100644 index 0000000..772f3ae --- /dev/null +++ b/tidal_dl_ng/model/downloader.py @@ -0,0 +1,24 @@ +import pathlib +from dataclasses import dataclass + +from requests import HTTPError +from tidalapi.media import Stream, StreamManifest + + +@dataclass +class DownloadSegmentResult: + result: bool + url: str + path_segment: pathlib.Path + id_segment: int + error: HTTPError | None = None + + +@dataclass +class TrackStreamInfo: + """Container for track stream information.""" + + stream_manifest: StreamManifest | None + file_extension: str + requires_flac_extraction: bool + media_stream: Stream | None diff --git a/tidal_dl_ng/model/gui_data.py b/tidal_dl_ng/model/gui_data.py new file mode 100644 index 0000000..eb58a48 --- /dev/null +++ b/tidal_dl_ng/model/gui_data.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass + +from tidalapi.media import Quality + +from tidal_dl_ng.constants import QualityVideo + +try: + from PySide6 import QtCore + + @dataclass + class ProgressBars: + item: QtCore.Signal + item_name: QtCore.Signal + list_item: QtCore.Signal + list_name: QtCore.Signal + +except ModuleNotFoundError: + + class ProgressBars: + pass + + +@dataclass +class ResultItem: + position: int + artist: str + title: str + album: str + duration_sec: int + obj: object + quality: str + explicit: bool + date_user_added: str + date_release: str + + +@dataclass +class StatusbarMessage: + message: str + timeout: int = 0 + + +@dataclass +class QueueDownloadItem: + status: str + name: str + type_media: str + quality_audio: Quality + quality_video: QualityVideo + obj: object diff --git a/tidal_dl_ng/model/meta.py b/tidal_dl_ng/model/meta.py new file mode 100644 index 0000000..1fd4abd --- /dev/null +++ b/tidal_dl_ng/model/meta.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass +class ReleaseLatest: + version: str + url: str + release_info: str + + +@dataclass +class ProjectInformation: + version: str + repository_url: str diff --git a/tidal_dl_ng/ui/__init__.py b/tidal_dl_ng/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tidal_dl_ng/ui/default_album_image.png b/tidal_dl_ng/ui/default_album_image.png new file mode 100644 index 0000000000000000000000000000000000000000..0ab51f11f3b2fd28edfe604c0f59cfb87b9e2e6b GIT binary patch literal 4960 zcmbVQcT^MGx=$t(82TWcV3;6Clon}%1rmCZqJSVMM3JHdMMOZPOlS%U(#1w1C}2TD zQL!KaL_rj3=XemsASiG^h@2=y+RM3bt@qb^_uhABty$mN-}mj`_uG4~*?Z3~%hTOa zMp9i8gTcr+JK62RU;y;*7s81=TNNzjMJCU4hqt}PKOSA(4JIaLmKIyBt!OUJZXO=q zKHmNY21Z(1S^@s_uuw)MBRW1VB|7@#etM{hv6-Enqm>nn!8q>Y<4-1&EiJc_NLtay z;u#Uqo*rHX1kioF_k@NrcKhtPQ1@h=h#L^I%iU{rrMYf)CkC@7$=QzP9gm&v!((wY42FID z8!L{ueXven%}RCOWxCB$31#b`*!-O@IZydn$r;#svNZL9N6-QY0}BQ{)@v&t5rj%a&wQJ zGuUyBzqs=8>e-dO7w+nhZyr3jK5O@%Oa}k}ngZJob^8|F_+HTmZUe!D0By@tc{lL- zPo1fpR3|HE@q_ZsC`u=&J=0t7uYjUWjL|E) z+~RC^6y+RkBXVib1T#0fOjX6lj&~G{+3yDicZx%22BuiqdQf9HQAUbMfv_hUM$~u= zJXXc&OGQvw*bD9ELiONimU-y0i>knpu7C#D!tfsr$<=RX{@8V!yh%+l9aRySBr0uH zQ1Os^H@#N>Kz&*&+!NHiZA01N$65YLhj4fj@2z*+50md+Ze;@=waiqQA16rWB;Kmb zul8ZE5l~}W{GiV5OAeLE8wo_4>m=T(cG+GdYw}}-jl|FF`m;)KlVfsx<9y&0R`#(Z zU|72_v{uoL`G z3u!Dw9`duC8E?xU`!1DwHx+GhXEl3LH2^ahII=m|J7w+;VdloEboBU(XzS(y?Y4z) zoDB4@FDIX71f9&zJ(Y#f=__Uic;_K71OXF?o0nfzxSqt3*!NOcWV-B}m<`ovH8wld z!EC=Ogk|BoteG~HRFt$3c36^lwfhqlTF`&GK=Onf;Ha6Eq;;uG*psn5`fmPNbFU5e zBtI$jr*G+rm)psZh4?KQR-cA5nzdOsCITXRd>MObv};t=mquOCJY`| zHe|5tm+n!zG*;(i$xn}A?pxS1cXn>2lTeMY6^je!MJ~}mjVFk!v5p1ybgiq0AdAal zV@|UgW)~blKzYqn^W2@Uy=SOgm(bO-+fG~F>7-G=r7snZhluHE!JILLABHY%yv&RRq+AU;-IGVs0xLPf5U;ltTwk(scD$_q0`22l=Q8D zvk{D(-{B~*Y*U(=OYG_2U}%Dp_UMFfNgBFRJrn@%IG_j$D($33+hpfm!8_JC>fH#toMr$D*jIv`YaZqg^7YKu@2Y>@A^{4x zZwzr$#e;k`^P9ibU$&2fqn?s>IJYwTWt!b?I+bcx{D_1(JeJVC+^-E6Rbsa%$NW($ zI(cQwmZ^B8PV(@|MMWUjr(3miEMh@&uYGiBel`_~zw!olm=(IXSmNi`DKX)m?hvT{ zM53Adn-KUfH9#y7m=Ufozo^KR0H5YFly%tViR&sa*~?ELyvT_?MQHIT0sdzHv*MsE zRQ;8LUz#YjHQ^sBdanj3n=Fu0K5$e%`jbyA#F1c1aS(KO+p>Z&Wp>gAVIP$%=&o7m z>etRKx7alJMPL81#x%laXsLZ_zPiENEu~!Y_%L_!GdczCL}g%@8yN4Fbc)t&_5R$VXX_J-pKwpJg!yqYCXO`$ zJh*797QVE&87l2u$#%7BZ-~hsMpU~(<;1Z^sShOXE)*G=p24xo4-V0x`0X{*n%71Z zp2>e?u=68%{!qM`i-6!Sx0)c+z+6rs@mx<4=8Rl8!0=aW(^e_HNDcJ_}58#qD@` zS*GQx9f(bOXOhFfEb*GM4`_$^i572dh)%%|-OUF3oQJZ^TdfntnJ`6Rht?NJSkLp- zrYTWiPNCj4(`GF~u%2zz7k>+JY|^5I&g!*b;N5fuvr^j_Ebgjx~`Ja?@R+kYvJ0->%K|sNsvY!PRAefHsEUi%#85jl)Dd$ayP-e)}0x_kg$4lMa>`cbyzGs5QT5r~WG2y*0W`0T^AuC`Q$Q+uYj z1j0UTe(8vv)RnGubcA&$nW!foP|}rVp){T~M`lIoD*%~^b^dXqOOrlJCRcgmTWzTM zM&yJlhfeE4u_7wM9_##5>AXCk*V&b&Xs9dMnA87Jl|%_{AvY9stu(UxSVhZ({ayFS zX_rQAxiRj(g4p7>u0eCY0(FR-()GQ;_gu;sUsKvAPEMHm6~2i~?X1^5b3Py@_uCGa zwtHDiOA#GX+q~1N9&5?1wx-;YIJB2J16;y^8S&Ka{TrQi)^f*>x=*d_ps+5`SOokd&0^N3fc=;}Sm>$t6 zr?d2WfCyw?b^nXUZ7TY$_}BN%msGdg;KZ@#KL_z+`4zDmC>lV3V41sC4$%YCT_2fT#zv?e7;jr_fYq;lhAQe)r|r!kyghh9tV5G z0xS7bM^j1cC@pzPRNVE+n+e$}5P9iUrlayt?z+|NAVUCa*5y&ZBwfV67iQpT%iZ}H zw$*CLJoP)fbSne74a#-88{QY( z8*|-$!9K>yhB|n*4@{7+=pYDPM-cj!s@QJX-JIIv>cs0XKUA{z@C#d7@c4<^k71C7 zzkF#P<<{84L7nXM4UT$P5^|zmFYNH;neO*tyUnsjUu>1dV^dy7?{u~gjobK%_S^Bj zTzwK-T(4|OV%5K<+`>7>xlr4G`}WsAv~dBD#R120XzvB)qYTulbjnKCA~%7E0jz>9 zq_zB2ZL11Z=cO$h^W}kG`HO;<4z3K1YW=gL#mZ}94UkZtNF1Vy?S^|U=mq9r4-2HF zvGWC&x8^ZP?9bQE`=(|GT|?N`!k$iL&IJ<+C?M7(-(a%wSqM+`-To!%4<8}1d85VQ zG-=VH$#@;+Mt?kRNn+0&*$@e12&w2ON0X9;egE+g1Ywodfq^fEZ{wNhK@O6r7WYsY z1a3`rMSxT(?8}O_X9KQ{TOS}WUybBxjIp66g+O+gF=aq9lL~SR5MF76zA7M(AtA;3 zje1g87}%)1SC)oJ7olkwKeaKG0Nj-U+6h~cXN5$mTm@cj01(i7SuQ^$t7#nWMsmfliyP^oINqW+@hgk$-b^YbPssbg2cq-)d_QE0@gM5MjcNO633-yk- zre@%kI$kbE%y?h07`Bri@w*eZKQcWN%?>IgVLje4snmr1F|9|nc_NKNhW}tgy=e5wr@`V@PLH}Mp^c2YuRy@s za39#J`MuJiLr!?3Ch5e`7EoGLvaX65VATSHif*m@?oUY2`)#Nr+S7tm0{j5PX1q%u z?6FzMX}?T?stH!4KnAXF0FFBR`&t%kFkpQ|0_eUbeeg+#lEsQ097PO4ZsTR?3sN+y zM9d4(EivI;;)lJ47|}|a=Iy)B!LmPqFi>#dB<{v?us!T~&Ssc;X!V&qs5oXnOx?5k zOvIwSgFv4AB%X@aO*jlwEmtoo11|kP3D7FS=3UZwV%DT*^;7sTLR1*jzBH`cHzYI9 zzl!km{f=p}CO^_2oevU)(sA?hO;47M02~#9aP#FWG^!lV%dMp%hm!*Lv~l?_H;L|{ zazKJ=b?ecT_T@B`D3-YoQr4f<1O=v?0Y&mJ%{fu06x=?gWTEbi$V=fY?!fM1*!(32 z^kpw@xXtfBikZ@c^UAi&=rp{M2zc2vtw`hYW5e+8WM&_+zB<7aZ(Q4?y;bt|@gP?? z>Su$R_*(G9^Dv4TjSI)`7a|h@%lO0<2=d7sR_NDB*PlDb%iNpfmGM{NNt@_%1sF%)1f8oSg-V{Pcp3F`78){cH8uIcV;c0 zz!#_i1%{T=_?l5}zY#e7GPX}LVL~POR|Yx|s(+V#I+@|0itazod#E(fS998FAq@qZ zoUHHO(f*+zn*w`ULBDl&+Q>E(WqEPn~7xFP)yrd$rL#6 zeSy))s~_4wfA}V+)?16%o)e6g|7iNwCDd||r*ZLPBY}JG*nJzldShM!)K21&8s~QQ zcTD)yjbNu`MbltkoVr}_L3;f%n3vg#e=bjt_f;p zl$^WUHf2MAO$*U{z2N{(=Ico9nKzjiuZdR1lW%s>xx9EwM0v8G_1$vqH^KxLdk}k# z=Q;O5wQzlN@Q#x4M3X1Bt{_&$F@jJgCLM^-t$*ixvju7B^2Nsen__EAlVNy&kD{48 z2-r3>Mx{G*{?z+ZmIySEgC;*+~aqo+Tz+Z=$c?+;qSwG@JxY^n=Oh$>61qe z3i}SYwJ}k8WY;-Ik_Vy*KK=U-`0HfZEeR^sNQ(7&%ItN2{Qb;RaFi)=V57^92TyqW zO4j$fY===at~^lhzQ4Wbyjj)FEf2q+uuG0V6ZEZ*DUHEkxJb&>sl@iY|6P~(*J8y# s#2K%DuDJXMLdib|{6Dmy|3t#UZt_9B$sro1qPf60+q>J{wV|i}7b49PLI3~& literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/dialog_login.py b/tidal_dl_ng/ui/dialog_login.py new file mode 100644 index 0000000..59a78da --- /dev/null +++ b/tidal_dl_ng/ui/dialog_login.py @@ -0,0 +1,119 @@ +################################################################################ +## Form generated from reading UI file 'dialog_login.ui' +## +## Created by: Qt User Interface Compiler version 6.8.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QDialogButtonBox, QHBoxLayout, QLabel, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget + + +class Ui_DialogLogin: + def setupUi(self, DialogLogin): + if not DialogLogin.objectName(): + DialogLogin.setObjectName("DialogLogin") + DialogLogin.resize(451, 400) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(DialogLogin.sizePolicy().hasHeightForWidth()) + DialogLogin.setSizePolicy(sizePolicy) + self.bb_dialog = QDialogButtonBox(DialogLogin) + self.bb_dialog.setObjectName("bb_dialog") + self.bb_dialog.setGeometry(QRect(20, 350, 411, 32)) + sizePolicy.setHeightForWidth(self.bb_dialog.sizePolicy().hasHeightForWidth()) + self.bb_dialog.setSizePolicy(sizePolicy) + self.bb_dialog.setLayoutDirection(Qt.LayoutDirection.LeftToRight) + self.bb_dialog.setStyleSheet("") + self.bb_dialog.setOrientation(Qt.Orientation.Horizontal) + self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) + self.verticalLayoutWidget = QWidget(DialogLogin) + self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") + self.verticalLayoutWidget.setGeometry(QRect(20, 20, 411, 325)) + self.lv_main = QVBoxLayout(self.verticalLayoutWidget) + self.lv_main.setObjectName("lv_main") + self.lv_main.setContentsMargins(0, 0, 0, 0) + self.l_header = QLabel(self.verticalLayoutWidget) + self.l_header.setObjectName("l_header") + sizePolicy.setHeightForWidth(self.l_header.sizePolicy().hasHeightForWidth()) + self.l_header.setSizePolicy(sizePolicy) + font = QFont() + font.setPointSize(23) + font.setBold(True) + self.l_header.setFont(font) + + self.lv_main.addWidget(self.l_header) + + self.l_description = QLabel(self.verticalLayoutWidget) + self.l_description.setObjectName("l_description") + sizePolicy.setHeightForWidth(self.l_description.sizePolicy().hasHeightForWidth()) + self.l_description.setSizePolicy(sizePolicy) + font1 = QFont() + font1.setItalic(True) + self.l_description.setFont(font1) + self.l_description.setWordWrap(True) + + self.lv_main.addWidget(self.l_description) + + self.tb_url_login = QTextBrowser(self.verticalLayoutWidget) + self.tb_url_login.setObjectName("tb_url_login") + self.tb_url_login.setOpenExternalLinks(True) + + self.lv_main.addWidget(self.tb_url_login) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.l_expires_description = QLabel(self.verticalLayoutWidget) + self.l_expires_description.setObjectName("l_expires_description") + + self.horizontalLayout.addWidget(self.l_expires_description) + + self.l_expires_date_time = QLabel(self.verticalLayoutWidget) + self.l_expires_date_time.setObjectName("l_expires_date_time") + font2 = QFont() + font2.setBold(True) + self.l_expires_date_time.setFont(font2) + self.l_expires_date_time.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter + ) + + self.horizontalLayout.addWidget(self.l_expires_date_time) + + self.lv_main.addLayout(self.horizontalLayout) + + self.l_hint = QLabel(self.verticalLayoutWidget) + self.l_hint.setObjectName("l_hint") + sizePolicy.setHeightForWidth(self.l_hint.sizePolicy().hasHeightForWidth()) + self.l_hint.setSizePolicy(sizePolicy) + self.l_hint.setFont(font1) + self.l_hint.setWordWrap(True) + + self.lv_main.addWidget(self.l_hint) + + self.retranslateUi(DialogLogin) + self.bb_dialog.accepted.connect(DialogLogin.accept) + self.bb_dialog.rejected.connect(DialogLogin.reject) + + QMetaObject.connectSlotsByName(DialogLogin) + + # setupUi + + def retranslateUi(self, DialogLogin): + DialogLogin.setWindowTitle(QCoreApplication.translate("DialogLogin", "Dialog", None)) + self.l_header.setText(QCoreApplication.translate("DialogLogin", "TIDAL Login (as Device)", None)) + self.l_description.setText( + QCoreApplication.translate( + "DialogLogin", + "Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this.", + None, + ) + ) + self.tb_url_login.setPlaceholderText(QCoreApplication.translate("DialogLogin", "Copy this login URL...", None)) + self.l_expires_description.setText(QCoreApplication.translate("DialogLogin", "This link expires at:", None)) + self.l_expires_date_time.setText(QCoreApplication.translate("DialogLogin", "COMPUTING", None)) + self.l_hint.setText(QCoreApplication.translate("DialogLogin", "Waiting...", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_login.ui b/tidal_dl_ng/ui/dialog_login.ui new file mode 100644 index 0000000..2ff401f --- /dev/null +++ b/tidal_dl_ng/ui/dialog_login.ui @@ -0,0 +1,195 @@ + + + DialogLogin + + + + 0 + 0 + 451 + 400 + + + + + 0 + 0 + + + + Dialog + + + + + 20 + 350 + 411 + 32 + + + + + 0 + 0 + + + + Qt::LayoutDirection::LeftToRight + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + 20 + 20 + 411 + 325 + + + + + + + + 0 + 0 + + + + + 23 + true + + + + TIDAL Login (as Device) + + + + + + + + 0 + 0 + + + + + true + + + + Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this. + + + true + + + + + + + Copy this login URL... + + + true + + + + + + + + + This link expires at: + + + + + + + + true + + + + COMPUTING + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + 0 + 0 + + + + + true + + + + Waiting... + + + true + + + + + + + + + + bb_dialog + accepted() + DialogLogin + accept() + + + 248 + 254 + + + 157 + 274 + + + + + bb_dialog + rejected() + DialogLogin + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/tidal_dl_ng/ui/dialog_settings.py b/tidal_dl_ng/ui/dialog_settings.py new file mode 100644 index 0000000..b7ae709 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_settings.py @@ -0,0 +1,660 @@ +################################################################################ +## Form generated from reading UI file 'dialog_settings.ui' +## +## Created by: Qt User Interface Compiler version 6.10.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject, Qt +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialogButtonBox, + QGroupBox, + QHBoxLayout, + QLabel, + QLayout, + QLineEdit, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, +) + + +class Ui_DialogSettings: + def setupUi(self, DialogSettings): + if not DialogSettings.objectName(): + DialogSettings.setObjectName("DialogSettings") + DialogSettings.resize(640, 832) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(100) + sizePolicy.setVerticalStretch(100) + sizePolicy.setHeightForWidth(DialogSettings.sizePolicy().hasHeightForWidth()) + DialogSettings.setSizePolicy(sizePolicy) + DialogSettings.setSizeGripEnabled(True) + self.lv_dialog_settings = QVBoxLayout(DialogSettings) + self.lv_dialog_settings.setObjectName("lv_dialog_settings") + self.lv_dialog_settings.setContentsMargins(0, 0, 0, 0) + self.lv_main = QVBoxLayout() + self.lv_main.setObjectName("lv_main") + self.lv_main.setContentsMargins(12, 12, 12, 12) + self.gb_flags = QGroupBox(DialogSettings) + self.gb_flags.setObjectName("gb_flags") + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy1.setHorizontalStretch(100) + sizePolicy1.setVerticalStretch(100) + sizePolicy1.setHeightForWidth(self.gb_flags.sizePolicy().hasHeightForWidth()) + self.gb_flags.setSizePolicy(sizePolicy1) + self.gb_flags.setFlat(False) + self.gb_flags.setCheckable(False) + self.lv_flags = QVBoxLayout(self.gb_flags) + self.lv_flags.setObjectName("lv_flags") + self.lh_flags_1 = QHBoxLayout() + self.lh_flags_1.setObjectName("lh_flags_1") + self.lv_flag_video_download = QVBoxLayout() + self.lv_flag_video_download.setObjectName("lv_flag_video_download") + self.cb_video_download = QCheckBox(self.gb_flags) + self.cb_video_download.setObjectName("cb_video_download") + sizePolicy1.setHeightForWidth(self.cb_video_download.sizePolicy().hasHeightForWidth()) + self.cb_video_download.setSizePolicy(sizePolicy1) + + self.lv_flag_video_download.addWidget(self.cb_video_download) + + self.lh_flags_1.addLayout(self.lv_flag_video_download) + + self.lv_flag_video_convert = QVBoxLayout() + self.lv_flag_video_convert.setObjectName("lv_flag_video_convert") + self.cb_video_convert_mp4 = QCheckBox(self.gb_flags) + self.cb_video_convert_mp4.setObjectName("cb_video_convert_mp4") + sizePolicy1.setHeightForWidth(self.cb_video_convert_mp4.sizePolicy().hasHeightForWidth()) + self.cb_video_convert_mp4.setSizePolicy(sizePolicy1) + + self.lv_flag_video_convert.addWidget(self.cb_video_convert_mp4) + + self.lh_flags_1.addLayout(self.lv_flag_video_convert) + + self.lv_flags.addLayout(self.lh_flags_1) + + self.lh_flags_2 = QHBoxLayout() + self.lh_flags_2.setObjectName("lh_flags_2") + self.lv_flag_lyrics_embed = QVBoxLayout() + self.lv_flag_lyrics_embed.setObjectName("lv_flag_lyrics_embed") + self.cb_lyrics_embed = QCheckBox(self.gb_flags) + self.cb_lyrics_embed.setObjectName("cb_lyrics_embed") + sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sizePolicy2.setHorizontalStretch(0) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.cb_lyrics_embed.sizePolicy().hasHeightForWidth()) + self.cb_lyrics_embed.setSizePolicy(sizePolicy2) + + self.lv_flag_lyrics_embed.addWidget(self.cb_lyrics_embed) + + self.lh_flags_2.addLayout(self.lv_flag_lyrics_embed) + + self.lv_flag_lyrics_file = QVBoxLayout() + self.lv_flag_lyrics_file.setObjectName("lv_flag_lyrics_file") + self.cb_lyrics_file = QCheckBox(self.gb_flags) + self.cb_lyrics_file.setObjectName("cb_lyrics_file") + sizePolicy1.setHeightForWidth(self.cb_lyrics_file.sizePolicy().hasHeightForWidth()) + self.cb_lyrics_file.setSizePolicy(sizePolicy1) + + self.lv_flag_lyrics_file.addWidget(self.cb_lyrics_file) + + self.lh_flags_2.addLayout(self.lv_flag_lyrics_file) + + self.lv_flags.addLayout(self.lh_flags_2) + + self.lh_flag_3 = QHBoxLayout() + self.lh_flag_3.setObjectName("lh_flag_3") + self.lv_flag_download_delay = QVBoxLayout() + self.lv_flag_download_delay.setObjectName("lv_flag_download_delay") + self.cb_download_delay = QCheckBox(self.gb_flags) + self.cb_download_delay.setObjectName("cb_download_delay") + sizePolicy1.setHeightForWidth(self.cb_download_delay.sizePolicy().hasHeightForWidth()) + self.cb_download_delay.setSizePolicy(sizePolicy1) + + self.lv_flag_download_delay.addWidget(self.cb_download_delay) + + self.lh_flag_3.addLayout(self.lv_flag_download_delay) + + self.lv_flag_extract_flac = QVBoxLayout() + self.lv_flag_extract_flac.setObjectName("lv_flag_extract_flac") + self.cb_extract_flac = QCheckBox(self.gb_flags) + self.cb_extract_flac.setObjectName("cb_extract_flac") + sizePolicy2.setHeightForWidth(self.cb_extract_flac.sizePolicy().hasHeightForWidth()) + self.cb_extract_flac.setSizePolicy(sizePolicy2) + + self.lv_flag_extract_flac.addWidget(self.cb_extract_flac) + + self.lh_flag_3.addLayout(self.lv_flag_extract_flac) + + self.lv_flags.addLayout(self.lh_flag_3) + + self.lh_flags_4 = QHBoxLayout() + self.lh_flags_4.setObjectName("lh_flags_4") + self.lv_flag_metadata_cover_embed = QVBoxLayout() + self.lv_flag_metadata_cover_embed.setObjectName("lv_flag_metadata_cover_embed") + self.cb_metadata_cover_embed = QCheckBox(self.gb_flags) + self.cb_metadata_cover_embed.setObjectName("cb_metadata_cover_embed") + + self.lv_flag_metadata_cover_embed.addWidget(self.cb_metadata_cover_embed) + + self.lh_flags_4.addLayout(self.lv_flag_metadata_cover_embed) + + self.lv_flag_cover_album_file = QVBoxLayout() + self.lv_flag_cover_album_file.setObjectName("lv_flag_cover_album_file") + self.cb_cover_album_file = QCheckBox(self.gb_flags) + self.cb_cover_album_file.setObjectName("cb_cover_album_file") + + self.lv_flag_cover_album_file.addWidget(self.cb_cover_album_file) + + self.lh_flags_4.addLayout(self.lv_flag_cover_album_file) + + self.lv_flags.addLayout(self.lh_flags_4) + + self.horizontalLayout = QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.lv_flag_skip_existing = QVBoxLayout() + self.lv_flag_skip_existing.setObjectName("lv_flag_skip_existing") + self.cb_skip_existing = QCheckBox(self.gb_flags) + self.cb_skip_existing.setObjectName("cb_skip_existing") + + self.lv_flag_skip_existing.addWidget(self.cb_skip_existing) + + self.horizontalLayout.addLayout(self.lv_flag_skip_existing) + + self.lv_symlink_to_track = QVBoxLayout() + self.lv_symlink_to_track.setObjectName("lv_symlink_to_track") + self.cb_symlink_to_track = QCheckBox(self.gb_flags) + self.cb_symlink_to_track.setObjectName("cb_symlink_to_track") + + self.lv_symlink_to_track.addWidget(self.cb_symlink_to_track) + + self.horizontalLayout.addLayout(self.lv_symlink_to_track) + + self.lv_flags.addLayout(self.horizontalLayout) + + self.horizontalLayout_12 = QHBoxLayout() + self.horizontalLayout_12.setObjectName("horizontalLayout_12") + self.lv_playlist_create = QVBoxLayout() + self.lv_playlist_create.setObjectName("lv_playlist_create") + self.cb_playlist_create = QCheckBox(self.gb_flags) + self.cb_playlist_create.setObjectName("cb_playlist_create") + + self.lv_playlist_create.addWidget(self.cb_playlist_create) + + self.horizontalLayout_12.addLayout(self.lv_playlist_create) + + self.lv_mark_explicit = QVBoxLayout() + self.lv_mark_explicit.setObjectName("lv_mark_explicit") + self.cb_mark_explicit = QCheckBox(self.gb_flags) + self.cb_mark_explicit.setObjectName("cb_mark_explicit") + + self.lv_mark_explicit.addWidget(self.cb_mark_explicit) + + self.horizontalLayout_12.addLayout(self.lv_mark_explicit) + + self.lv_flags.addLayout(self.horizontalLayout_12) + + self.horizontalLayout_13 = QHBoxLayout() + self.horizontalLayout_13.setObjectName("horizontalLayout_13") + self.lv_use_primary_album_artist = QVBoxLayout() + self.lv_use_primary_album_artist.setObjectName("lv_use_primary_album_artist") + self.cb_use_primary_album_artist = QCheckBox(self.gb_flags) + self.cb_use_primary_album_artist.setObjectName("cb_use_primary_album_artist") + + self.lv_use_primary_album_artist.addWidget(self.cb_use_primary_album_artist) + + self.horizontalLayout_13.addLayout(self.lv_use_primary_album_artist) + + self.lv_download_dolby_atmos = QVBoxLayout() + self.lv_download_dolby_atmos.setObjectName("lv_download_dolby_atmos") + self.cb_download_dolby_atmos = QCheckBox(self.gb_flags) + self.cb_download_dolby_atmos.setObjectName("cb_download_dolby_atmos") + + self.lv_download_dolby_atmos.addWidget(self.cb_download_dolby_atmos) + + self.horizontalLayout_13.addLayout(self.lv_download_dolby_atmos) + + self.lv_flags.addLayout(self.horizontalLayout_13) + + self.lv_main.addWidget(self.gb_flags) + + self.gb_choices = QGroupBox(DialogSettings) + self.gb_choices.setObjectName("gb_choices") + sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + sizePolicy3.setHorizontalStretch(0) + sizePolicy3.setVerticalStretch(0) + sizePolicy3.setHeightForWidth(self.gb_choices.sizePolicy().hasHeightForWidth()) + self.gb_choices.setSizePolicy(sizePolicy3) + self.lv_choices = QVBoxLayout(self.gb_choices) + self.lv_choices.setObjectName("lv_choices") + self.lh_choices_quality_audio = QHBoxLayout() + self.lh_choices_quality_audio.setObjectName("lh_choices_quality_audio") + self.l_icon_quality_audio = QLabel(self.gb_choices) + self.l_icon_quality_audio.setObjectName("l_icon_quality_audio") + sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + sizePolicy4.setHorizontalStretch(0) + sizePolicy4.setVerticalStretch(0) + sizePolicy4.setHeightForWidth(self.l_icon_quality_audio.sizePolicy().hasHeightForWidth()) + self.l_icon_quality_audio.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_audio.addWidget(self.l_icon_quality_audio) + + self.l_quality_audio = QLabel(self.gb_choices) + self.l_quality_audio.setObjectName("l_quality_audio") + sizePolicy4.setHeightForWidth(self.l_quality_audio.sizePolicy().hasHeightForWidth()) + self.l_quality_audio.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_audio.addWidget(self.l_quality_audio) + + self.c_quality_audio = QComboBox(self.gb_choices) + self.c_quality_audio.setObjectName("c_quality_audio") + + self.lh_choices_quality_audio.addWidget(self.c_quality_audio) + + self.lh_choices_quality_audio.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_quality_audio) + + self.lh_choices_quality_video = QHBoxLayout() + self.lh_choices_quality_video.setObjectName("lh_choices_quality_video") + self.l_icon_quality_video = QLabel(self.gb_choices) + self.l_icon_quality_video.setObjectName("l_icon_quality_video") + sizePolicy4.setHeightForWidth(self.l_icon_quality_video.sizePolicy().hasHeightForWidth()) + self.l_icon_quality_video.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_video.addWidget(self.l_icon_quality_video) + + self.l_quality_video = QLabel(self.gb_choices) + self.l_quality_video.setObjectName("l_quality_video") + sizePolicy4.setHeightForWidth(self.l_quality_video.sizePolicy().hasHeightForWidth()) + self.l_quality_video.setSizePolicy(sizePolicy4) + + self.lh_choices_quality_video.addWidget(self.l_quality_video) + + self.c_quality_video = QComboBox(self.gb_choices) + self.c_quality_video.setObjectName("c_quality_video") + + self.lh_choices_quality_video.addWidget(self.c_quality_video) + + self.lh_choices_quality_video.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_quality_video) + + self.lh_choices_cover_dimension = QHBoxLayout() + self.lh_choices_cover_dimension.setObjectName("lh_choices_cover_dimension") + self.lh_choices_cover_dimension.setSizeConstraint(QLayout.SizeConstraint.SetDefaultConstraint) + self.l_icon_metadata_cover_dimension = QLabel(self.gb_choices) + self.l_icon_metadata_cover_dimension.setObjectName("l_icon_metadata_cover_dimension") + sizePolicy4.setHeightForWidth(self.l_icon_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.l_icon_metadata_cover_dimension.setSizePolicy(sizePolicy4) + + self.lh_choices_cover_dimension.addWidget(self.l_icon_metadata_cover_dimension) + + self.l_metadata_cover_dimension = QLabel(self.gb_choices) + self.l_metadata_cover_dimension.setObjectName("l_metadata_cover_dimension") + sizePolicy4.setHeightForWidth(self.l_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.l_metadata_cover_dimension.setSizePolicy(sizePolicy4) + + self.lh_choices_cover_dimension.addWidget(self.l_metadata_cover_dimension) + + self.c_metadata_cover_dimension = QComboBox(self.gb_choices) + self.c_metadata_cover_dimension.setObjectName("c_metadata_cover_dimension") + sizePolicy5 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + sizePolicy5.setHorizontalStretch(10) + sizePolicy5.setVerticalStretch(0) + sizePolicy5.setHeightForWidth(self.c_metadata_cover_dimension.sizePolicy().hasHeightForWidth()) + self.c_metadata_cover_dimension.setSizePolicy(sizePolicy5) + + self.lh_choices_cover_dimension.addWidget(self.c_metadata_cover_dimension) + + self.lh_choices_cover_dimension.setStretch(2, 50) + + self.lv_choices.addLayout(self.lh_choices_cover_dimension) + + self.lv_main.addWidget(self.gb_choices) + + self.gb_numbers = QGroupBox(DialogSettings) + self.gb_numbers.setObjectName("gb_numbers") + self.verticalLayout_8 = QVBoxLayout(self.gb_numbers) + self.verticalLayout_8.setObjectName("verticalLayout_8") + self.horizontalLayout_9 = QHBoxLayout() + self.horizontalLayout_9.setObjectName("horizontalLayout_9") + self.l_album_track_num_pad_min = QLabel(self.gb_numbers) + self.l_album_track_num_pad_min.setObjectName("l_album_track_num_pad_min") + + self.horizontalLayout_9.addWidget(self.l_album_track_num_pad_min) + + self.l_icon_album_track_num_pad_min = QLabel(self.gb_numbers) + self.l_icon_album_track_num_pad_min.setObjectName("l_icon_album_track_num_pad_min") + + self.horizontalLayout_9.addWidget(self.l_icon_album_track_num_pad_min) + + self.sb_album_track_num_pad_min = QSpinBox(self.gb_numbers) + self.sb_album_track_num_pad_min.setObjectName("sb_album_track_num_pad_min") + self.sb_album_track_num_pad_min.setMaximum(4) + + self.horizontalLayout_9.addWidget(self.sb_album_track_num_pad_min) + + self.verticalLayout_8.addLayout(self.horizontalLayout_9) + + self.horizontalLayout_11 = QHBoxLayout() + self.horizontalLayout_11.setObjectName("horizontalLayout_11") + self.l_downloads_concurrent_max = QLabel(self.gb_numbers) + self.l_downloads_concurrent_max.setObjectName("l_downloads_concurrent_max") + + self.horizontalLayout_11.addWidget(self.l_downloads_concurrent_max) + + self.l_icon_downloads_concurrent_max = QLabel(self.gb_numbers) + self.l_icon_downloads_concurrent_max.setObjectName("l_icon_downloads_concurrent_max") + + self.horizontalLayout_11.addWidget(self.l_icon_downloads_concurrent_max) + + self.sb_downloads_concurrent_max = QSpinBox(self.gb_numbers) + self.sb_downloads_concurrent_max.setObjectName("sb_downloads_concurrent_max") + self.sb_downloads_concurrent_max.setMinimum(1) + self.sb_downloads_concurrent_max.setMaximum(5) + + self.horizontalLayout_11.addWidget(self.sb_downloads_concurrent_max) + + self.verticalLayout_8.addLayout(self.horizontalLayout_11) + + self.lv_main.addWidget(self.gb_numbers) + + self.gb_path = QGroupBox(DialogSettings) + self.gb_path.setObjectName("gb_path") + self.horizontalLayout_2 = QHBoxLayout(self.gb_path) + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.verticalLayout_2 = QVBoxLayout() + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.lh_path_base = QHBoxLayout() + self.lh_path_base.setObjectName("lh_path_base") + self.l_icon_download_base_path = QLabel(self.gb_path) + self.l_icon_download_base_path.setObjectName("l_icon_download_base_path") + sizePolicy4.setHeightForWidth(self.l_icon_download_base_path.sizePolicy().hasHeightForWidth()) + self.l_icon_download_base_path.setSizePolicy(sizePolicy4) + + self.lh_path_base.addWidget(self.l_icon_download_base_path) + + self.l_download_base_path = QLabel(self.gb_path) + self.l_download_base_path.setObjectName("l_download_base_path") + sizePolicy3.setHeightForWidth(self.l_download_base_path.sizePolicy().hasHeightForWidth()) + self.l_download_base_path.setSizePolicy(sizePolicy3) + + self.lh_path_base.addWidget(self.l_download_base_path) + + self.verticalLayout_2.addLayout(self.lh_path_base) + + self.lh_path_fmt_track = QHBoxLayout() + self.lh_path_fmt_track.setObjectName("lh_path_fmt_track") + self.l_icon_format_track = QLabel(self.gb_path) + self.l_icon_format_track.setObjectName("l_icon_format_track") + sizePolicy4.setHeightForWidth(self.l_icon_format_track.sizePolicy().hasHeightForWidth()) + self.l_icon_format_track.setSizePolicy(sizePolicy4) + + self.lh_path_fmt_track.addWidget(self.l_icon_format_track) + + self.l_format_track = QLabel(self.gb_path) + self.l_format_track.setObjectName("l_format_track") + sizePolicy3.setHeightForWidth(self.l_format_track.sizePolicy().hasHeightForWidth()) + self.l_format_track.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_track.addWidget(self.l_format_track) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_track) + + self.lh_path_fmt_video = QHBoxLayout() + self.lh_path_fmt_video.setObjectName("lh_path_fmt_video") + self.l_icon_format_video = QLabel(self.gb_path) + self.l_icon_format_video.setObjectName("l_icon_format_video") + sizePolicy4.setHeightForWidth(self.l_icon_format_video.sizePolicy().hasHeightForWidth()) + self.l_icon_format_video.setSizePolicy(sizePolicy4) + + self.lh_path_fmt_video.addWidget(self.l_icon_format_video) + + self.l_format_video = QLabel(self.gb_path) + self.l_format_video.setObjectName("l_format_video") + sizePolicy3.setHeightForWidth(self.l_format_video.sizePolicy().hasHeightForWidth()) + self.l_format_video.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_video.addWidget(self.l_format_video) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_video) + + self.lh_path_fmt_album = QHBoxLayout() + self.lh_path_fmt_album.setObjectName("lh_path_fmt_album") + self.l_icon_format_album = QLabel(self.gb_path) + self.l_icon_format_album.setObjectName("l_icon_format_album") + sizePolicy6 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + sizePolicy6.setHorizontalStretch(0) + sizePolicy6.setVerticalStretch(0) + sizePolicy6.setHeightForWidth(self.l_icon_format_album.sizePolicy().hasHeightForWidth()) + self.l_icon_format_album.setSizePolicy(sizePolicy6) + + self.lh_path_fmt_album.addWidget(self.l_icon_format_album) + + self.l_format_album = QLabel(self.gb_path) + self.l_format_album.setObjectName("l_format_album") + sizePolicy3.setHeightForWidth(self.l_format_album.sizePolicy().hasHeightForWidth()) + self.l_format_album.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_album.addWidget(self.l_format_album) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_album) + + self.lh_fpath_mt_playlist = QHBoxLayout() + self.lh_fpath_mt_playlist.setObjectName("lh_fpath_mt_playlist") + self.l_icon_format_playlist = QLabel(self.gb_path) + self.l_icon_format_playlist.setObjectName("l_icon_format_playlist") + sizePolicy4.setHeightForWidth(self.l_icon_format_playlist.sizePolicy().hasHeightForWidth()) + self.l_icon_format_playlist.setSizePolicy(sizePolicy4) + + self.lh_fpath_mt_playlist.addWidget(self.l_icon_format_playlist) + + self.l_format_playlist = QLabel(self.gb_path) + self.l_format_playlist.setObjectName("l_format_playlist") + sizePolicy3.setHeightForWidth(self.l_format_playlist.sizePolicy().hasHeightForWidth()) + self.l_format_playlist.setSizePolicy(sizePolicy3) + + self.lh_fpath_mt_playlist.addWidget(self.l_format_playlist) + + self.verticalLayout_2.addLayout(self.lh_fpath_mt_playlist) + + self.lh_path_fmt_mix = QHBoxLayout() + self.lh_path_fmt_mix.setObjectName("lh_path_fmt_mix") + self.l_icon_format_mix = QLabel(self.gb_path) + self.l_icon_format_mix.setObjectName("l_icon_format_mix") + sizePolicy6.setHeightForWidth(self.l_icon_format_mix.sizePolicy().hasHeightForWidth()) + self.l_icon_format_mix.setSizePolicy(sizePolicy6) + + self.lh_path_fmt_mix.addWidget(self.l_icon_format_mix) + + self.l_format_mix = QLabel(self.gb_path) + self.l_format_mix.setObjectName("l_format_mix") + sizePolicy3.setHeightForWidth(self.l_format_mix.sizePolicy().hasHeightForWidth()) + self.l_format_mix.setSizePolicy(sizePolicy3) + + self.lh_path_fmt_mix.addWidget(self.l_format_mix) + + self.verticalLayout_2.addLayout(self.lh_path_fmt_mix) + + self.lh_path_binary_ffmpeg = QHBoxLayout() + self.lh_path_binary_ffmpeg.setObjectName("lh_path_binary_ffmpeg") + self.l_icon_path_binary_ffmpeg = QLabel(self.gb_path) + self.l_icon_path_binary_ffmpeg.setObjectName("l_icon_path_binary_ffmpeg") + sizePolicy4.setHeightForWidth(self.l_icon_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.l_icon_path_binary_ffmpeg.setSizePolicy(sizePolicy4) + + self.lh_path_binary_ffmpeg.addWidget(self.l_icon_path_binary_ffmpeg) + + self.l_path_binary_ffmpeg = QLabel(self.gb_path) + self.l_path_binary_ffmpeg.setObjectName("l_path_binary_ffmpeg") + sizePolicy3.setHeightForWidth(self.l_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.l_path_binary_ffmpeg.setSizePolicy(sizePolicy3) + + self.lh_path_binary_ffmpeg.addWidget(self.l_path_binary_ffmpeg) + + self.verticalLayout_2.addLayout(self.lh_path_binary_ffmpeg) + + self.horizontalLayout_2.addLayout(self.verticalLayout_2) + + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + self.horizontalLayout_10 = QHBoxLayout() + self.horizontalLayout_10.setObjectName("horizontalLayout_10") + self.le_download_base_path = QLineEdit(self.gb_path) + self.le_download_base_path.setObjectName("le_download_base_path") + sizePolicy2.setHeightForWidth(self.le_download_base_path.sizePolicy().hasHeightForWidth()) + self.le_download_base_path.setSizePolicy(sizePolicy2) + self.le_download_base_path.setDragEnabled(True) + + self.horizontalLayout_10.addWidget(self.le_download_base_path) + + self.pb_download_base_path = QPushButton(self.gb_path) + self.pb_download_base_path.setObjectName("pb_download_base_path") + + self.horizontalLayout_10.addWidget(self.pb_download_base_path) + + self.verticalLayout.addLayout(self.horizontalLayout_10) + + self.horizontalLayout_7 = QHBoxLayout() + self.horizontalLayout_7.setObjectName("horizontalLayout_7") + self.le_format_track = QLineEdit(self.gb_path) + self.le_format_track.setObjectName("le_format_track") + + self.horizontalLayout_7.addWidget(self.le_format_track) + + self.verticalLayout.addLayout(self.horizontalLayout_7) + + self.horizontalLayout_5 = QHBoxLayout() + self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.le_format_video = QLineEdit(self.gb_path) + self.le_format_video.setObjectName("le_format_video") + + self.horizontalLayout_5.addWidget(self.le_format_video) + + self.verticalLayout.addLayout(self.horizontalLayout_5) + + self.horizontalLayout_6 = QHBoxLayout() + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.le_format_album = QLineEdit(self.gb_path) + self.le_format_album.setObjectName("le_format_album") + + self.horizontalLayout_6.addWidget(self.le_format_album) + + self.verticalLayout.addLayout(self.horizontalLayout_6) + + self.horizontalLayout_4 = QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.le_format_playlist = QLineEdit(self.gb_path) + self.le_format_playlist.setObjectName("le_format_playlist") + sizePolicy2.setHeightForWidth(self.le_format_playlist.sizePolicy().hasHeightForWidth()) + self.le_format_playlist.setSizePolicy(sizePolicy2) + + self.horizontalLayout_4.addWidget(self.le_format_playlist) + + self.verticalLayout.addLayout(self.horizontalLayout_4) + + self.horizontalLayout_8 = QHBoxLayout() + self.horizontalLayout_8.setObjectName("horizontalLayout_8") + self.le_format_mix = QLineEdit(self.gb_path) + self.le_format_mix.setObjectName("le_format_mix") + + self.horizontalLayout_8.addWidget(self.le_format_mix) + + self.verticalLayout.addLayout(self.horizontalLayout_8) + + self.horizontalLayout_3 = QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.le_path_binary_ffmpeg = QLineEdit(self.gb_path) + self.le_path_binary_ffmpeg.setObjectName("le_path_binary_ffmpeg") + sizePolicy2.setHeightForWidth(self.le_path_binary_ffmpeg.sizePolicy().hasHeightForWidth()) + self.le_path_binary_ffmpeg.setSizePolicy(sizePolicy2) + self.le_path_binary_ffmpeg.setDragEnabled(True) + + self.horizontalLayout_3.addWidget(self.le_path_binary_ffmpeg) + + self.pb_path_binary_ffmpeg = QPushButton(self.gb_path) + self.pb_path_binary_ffmpeg.setObjectName("pb_path_binary_ffmpeg") + + self.horizontalLayout_3.addWidget(self.pb_path_binary_ffmpeg) + + self.verticalLayout.addLayout(self.horizontalLayout_3) + + self.horizontalLayout_2.addLayout(self.verticalLayout) + + self.horizontalLayout_2.setStretch(1, 50) + + self.lv_main.addWidget(self.gb_path) + + self.bb_dialog = QDialogButtonBox(DialogSettings) + self.bb_dialog.setObjectName("bb_dialog") + self.bb_dialog.setOrientation(Qt.Orientation.Horizontal) + self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) + + self.lv_main.addWidget(self.bb_dialog) + + self.lv_dialog_settings.addLayout(self.lv_main) + + self.retranslateUi(DialogSettings) + self.bb_dialog.accepted.connect(DialogSettings.accept) + self.bb_dialog.rejected.connect(DialogSettings.reject) + + QMetaObject.connectSlotsByName(DialogSettings) + + # setupUi + + def retranslateUi(self, DialogSettings): + DialogSettings.setWindowTitle(QCoreApplication.translate("DialogSettings", "Preferences", None)) + self.gb_flags.setTitle(QCoreApplication.translate("DialogSettings", "Flags", None)) + self.cb_video_download.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_video_convert_mp4.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + # if QT_CONFIG(whatsthis) + self.cb_lyrics_embed.setWhatsThis("") + # endif // QT_CONFIG(whatsthis) + self.cb_lyrics_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_lyrics_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_download_delay.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_extract_flac.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_symlink_to_track.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_playlist_create.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_mark_explicit.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_use_primary_album_artist.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.cb_download_dolby_atmos.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) + self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None)) + self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_quality_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_metadata_cover_dimension.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.gb_numbers.setTitle(QCoreApplication.translate("DialogSettings", "Numbers", None)) + self.l_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_album_track_num_pad_min.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_downloads_concurrent_max.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.gb_path.setTitle(QCoreApplication.translate("DialogSettings", "Path", None)) + self.l_icon_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_download_base_path.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_track.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_video.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_album.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_playlist.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_format_mix.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_icon_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.l_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) + self.pb_download_base_path.setText(QCoreApplication.translate("DialogSettings", "...", None)) + self.pb_path_binary_ffmpeg.setText(QCoreApplication.translate("DialogSettings", "...", None)) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_settings.ui b/tidal_dl_ng/ui/dialog_settings.ui new file mode 100644 index 0000000..3161095 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_settings.ui @@ -0,0 +1,846 @@ + + + DialogSettings + + + + 0 + 0 + 640 + 832 + + + + + 100 + 100 + + + + Preferences + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 12 + + + 12 + + + 12 + + + 12 + + + + + + 100 + 100 + + + + Flags + + + false + + + false + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + CheckBox + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + + + + + 100 + 100 + + + + CheckBox + + + + + + + + + + + + 0 + 0 + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + + + + + + CheckBox + + + + + + + + + + + + + + + 0 + 0 + + + + Choices + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 10 + 0 + + + + + + + + + + + + + Numbers + + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + 4 + + + + + + + + + + + TextLabel + + + + + + + TextLabel + + + + + + + 1 + + + 5 + + + + + + + + + + + + Path + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + 0 + 0 + + + + TextLabel + + + + + + + + + + + + + + + + 0 + 0 + + + + true + + + + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + true + + + + + + + ... + + + + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + + + bb_dialog + accepted() + DialogSettings + accept() + + + 319 + 661 + + + 319 + 340 + + + + + bb_dialog + rejected() + DialogSettings + reject() + + + 319 + 661 + + + 319 + 340 + + + + + diff --git a/tidal_dl_ng/ui/dialog_version.py b/tidal_dl_ng/ui/dialog_version.py new file mode 100644 index 0000000..b8cf294 --- /dev/null +++ b/tidal_dl_ng/ui/dialog_version.py @@ -0,0 +1,161 @@ +################################################################################ +## Form generated from reading UI file 'dialog_version.ui' +## +## Created by: Qt User Interface Compiler version 6.6.1 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import QCoreApplication, QMetaObject, QSize, Qt +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QVBoxLayout + + +class Ui_DialogVersion: + def setupUi(self, DialogVersion): + if not DialogVersion.objectName(): + DialogVersion.setObjectName("DialogVersion") + DialogVersion.resize(436, 235) + sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(DialogVersion.sizePolicy().hasHeightForWidth()) + DialogVersion.setSizePolicy(sizePolicy) + DialogVersion.setMaximumSize(QSize(436, 235)) + self.verticalLayout = QVBoxLayout(DialogVersion) + self.verticalLayout.setObjectName("verticalLayout") + self.l_name_app = QLabel(DialogVersion) + self.l_name_app.setObjectName("l_name_app") + sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + sizePolicy1.setHorizontalStretch(0) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.l_name_app.sizePolicy().hasHeightForWidth()) + self.l_name_app.setSizePolicy(sizePolicy1) + font = QFont() + font.setBold(True) + self.l_name_app.setFont(font) + self.l_name_app.setAlignment(Qt.AlignCenter) + self.l_name_app.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.verticalLayout.addWidget(self.l_name_app) + + self.lv_version = QVBoxLayout() + self.lv_version.setObjectName("lv_version") + self.lh_version = QHBoxLayout() + self.lh_version.setObjectName("lh_version") + self.l_h_version = QLabel(DialogVersion) + self.l_h_version.setObjectName("l_h_version") + self.l_h_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_version.addWidget(self.l_h_version) + + self.l_version = QLabel(DialogVersion) + self.l_version.setObjectName("l_version") + self.l_version.setFont(font) + self.l_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_version.addWidget(self.l_version) + + self.lv_version.addLayout(self.lh_version) + + self.verticalLayout.addLayout(self.lv_version) + + self.lv_update = QVBoxLayout() + self.lv_update.setObjectName("lv_update") + self.l_error = QLabel(DialogVersion) + self.l_error.setObjectName("l_error") + self.l_error.setFont(font) + self.l_error.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_error) + + self.l_error_details = QLabel(DialogVersion) + self.l_error_details.setObjectName("l_error_details") + self.l_error_details.setFont(font) + self.l_error_details.setAlignment(Qt.AlignCenter) + self.l_error_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_error_details) + + self.lh_update_version = QHBoxLayout() + self.lh_update_version.setObjectName("lh_update_version") + self.l_h_version_new = QLabel(DialogVersion) + self.l_h_version_new.setObjectName("l_h_version_new") + self.l_h_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_update_version.addWidget(self.l_h_version_new) + + self.l_version_new = QLabel(DialogVersion) + self.l_version_new.setObjectName("l_version_new") + self.l_version_new.setFont(font) + self.l_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lh_update_version.addWidget(self.l_version_new) + + self.lv_update.addLayout(self.lh_update_version) + + self.l_changelog = QLabel(DialogVersion) + self.l_changelog.setObjectName("l_changelog") + self.l_changelog.setFont(font) + self.l_changelog.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_changelog) + + self.l_changelog_details = QLabel(DialogVersion) + self.l_changelog_details.setObjectName("l_changelog_details") + self.l_changelog_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.lv_update.addWidget(self.l_changelog_details) + + self.lv_download = QHBoxLayout() + self.lv_download.setObjectName("lv_download") + self.lv_download.setContentsMargins(-1, 20, -1, -1) + self.pb_download = QPushButton(DialogVersion) + self.pb_download.setObjectName("pb_download") + self.pb_download.setFlat(False) + + self.lv_download.addWidget(self.pb_download) + + self.sh_download = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) + + self.lv_download.addItem(self.sh_download) + + self.lv_update.addLayout(self.lv_download) + + self.verticalLayout.addLayout(self.lv_update) + + self.l_url_github = QLabel(DialogVersion) + self.l_url_github.setObjectName("l_url_github") + self.l_url_github.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) + self.l_url_github.setOpenExternalLinks(True) + self.l_url_github.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) + + self.verticalLayout.addWidget(self.l_url_github) + + self.retranslateUi(DialogVersion) + + QMetaObject.connectSlotsByName(DialogVersion) + + # setupUi + + def retranslateUi(self, DialogVersion): + DialogVersion.setWindowTitle(QCoreApplication.translate("DialogVersion", "Version", None)) + self.l_name_app.setText(QCoreApplication.translate("DialogVersion", "TIDAL Downloader Next Generation!", None)) + self.l_h_version.setText(QCoreApplication.translate("DialogVersion", "Installed Version:", None)) + self.l_version.setText(QCoreApplication.translate("DialogVersion", "v1.2.3", None)) + self.l_error.setText(QCoreApplication.translate("DialogVersion", "ERROR", None)) + self.l_error_details.setText(QCoreApplication.translate("DialogVersion", "", None)) + self.l_h_version_new.setText(QCoreApplication.translate("DialogVersion", "New Version Available:", None)) + self.l_version_new.setText(QCoreApplication.translate("DialogVersion", "v0.0.0", None)) + self.l_changelog.setText(QCoreApplication.translate("DialogVersion", "Changelog", None)) + self.l_changelog_details.setText(QCoreApplication.translate("DialogVersion", "", None)) + self.pb_download.setText(QCoreApplication.translate("DialogVersion", "Download", None)) + self.l_url_github.setText( + QCoreApplication.translate( + "DialogVersion", + 'https://github.com/exislow/tidal-dl-ng/', + None, + ) + ) + + # retranslateUi diff --git a/tidal_dl_ng/ui/dialog_version.ui b/tidal_dl_ng/ui/dialog_version.ui new file mode 100644 index 0000000..0cea9bd --- /dev/null +++ b/tidal_dl_ng/ui/dialog_version.ui @@ -0,0 +1,227 @@ + + + DialogVersion + + + + 0 + 0 + 436 + 235 + + + + + 0 + 0 + + + + + 436 + 235 + + + + Version + + + + + + + 0 + 0 + + + + + true + + + + TIDAL Downloader Next Generation! + + + Qt::AlignCenter + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + Installed Version: + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + v1.2.3 + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + + + + + true + + + + ERROR + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + <ERROR> + + + Qt::AlignCenter + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + New Version Available: + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + true + + + + v0.0.0 + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + true + + + + Changelog + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + <CHANGELOG> + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + 20 + + + + + Download + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + <a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a> + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + diff --git a/tidal_dl_ng/ui/dummy_register.py b/tidal_dl_ng/ui/dummy_register.py new file mode 100644 index 0000000..dfffdc2 --- /dev/null +++ b/tidal_dl_ng/ui/dummy_register.py @@ -0,0 +1,33 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + +from .dummy_wiggly import WigglyWidget + +# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin + + +TOOLTIP = "A cool wiggly widget (Python)" +DOM_XML = """ + + + + + 0 + 0 + 400 + 200 + + + + Hello, world + + + +""" + +if __name__ == "__main__": + QPyDesignerCustomWidgetCollection.registerCustomWidget( + WigglyWidget, module="wigglywidget", tool_tip=TOOLTIP, xml=DOM_XML + ) diff --git a/tidal_dl_ng/ui/dummy_wiggly.py b/tidal_dl_ng/ui/dummy_wiggly.py new file mode 100644 index 0000000..e6eb3d3 --- /dev/null +++ b/tidal_dl_ng/ui/dummy_wiggly.py @@ -0,0 +1,68 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from PySide6.QtCore import Property, QBasicTimer +from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette +from PySide6.QtWidgets import QWidget + + +class WigglyWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self._step = 0 + self._text = "" + self.setBackgroundRole(QPalette.Midlight) + self.setAutoFillBackground(True) + + new_font = self.font() + new_font.setPointSize(new_font.pointSize() + 20) + self.setFont(new_font) + self._timer = QBasicTimer() + + def isRunning(self): + return self._timer.isActive() + + def setRunning(self, r): + if r == self.isRunning(): + return + if r: + self._timer.start(60, self) + else: + self._timer.stop() + + def paintEvent(self, event): + if not self._text: + return + + sineTable = [0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38] + + metrics = QFontMetrics(self.font()) + x = (self.width() - metrics.horizontalAdvance(self.text)) / 2 + y = (self.height() + metrics.ascent() - metrics.descent()) / 2 + color = QColor() + + with QPainter(self) as painter: + for i in range(len(self.text)): + index = (self._step + i) % 16 + color.setHsv((15 - index) * 16, 255, 191) + painter.setPen(color) + dy = (sineTable[index] * metrics.height()) / 400 + c = self._text[i] + painter.drawText(x, y - dy, str(c)) + x += metrics.horizontalAdvance(c) + + def timerEvent(self, event): + if event.timerId() == self._timer.timerId(): + self._step += 1 + self.update() + else: + QWidget.timerEvent(event) + + def text(self): + return self._text + + def setText(self, text): + self._text = text + + running = Property(bool, isRunning, setRunning) + text = Property(str, text, setText) diff --git a/tidal_dl_ng/ui/icon.icns b/tidal_dl_ng/ui/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..f95b70a41060075cdf3ba20d6f875c1820b327ed GIT binary patch literal 159805 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

Ife^K@{Y5_+p)MbZGeBv>MeI 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�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

ItI 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 literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon.ico b/tidal_dl_ng/ui/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d2a5d281889b45771be35bf321d3a65e71a15ef2 GIT binary patch literal 196338 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 RWY}3^Yu(Z6U@N=X{{srvfcgLc literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon16.png b/tidal_dl_ng/ui/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..c57931de80affa9066dde53596158c6584dd6dc2 GIT binary patch literal 382 zcmV-^0fGLBP)bld+1zKomufNx;TZ(%7U_EK?_?jf5#v`3e4lZ0B$I z3#LmKt3XOC8zHTeDliBG!fdk%x{0#8vIhocc=uiA&SM6E;SH>JnqL~^d5$p#V+?to zKQ(Y(xo@{y9LFIDg1;GrVTf(puO!_Fp69ju`Fzf7HtVf5*uY0~Sg+S4Ny71XBnSd_ zyB+8AnaN~ARaG1g2kN?}D2mo#z}FZ(&*OADF&qw&QX-`!O;c{S8_VSq*L87S7p*nZ z>6Ef8A0w1yNgT&WDT$&8r4-}w7~l6%N)bg7QcB`DZkr!&&@dW}P)f1cY*;K7TrL-` z*DI^ls;%AAnL;D5EQ>75+DP}c@0h+NgfLobqqR0dh+Yj~IuY&>LIAMe@1IG!Z}40G c?=$_BPl>dNFcMXH0{{R307*qoM6N<$f~$U<+yDRo literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon256.png b/tidal_dl_ng/ui/icon256.png new file mode 100644 index 0000000000000000000000000000000000000000..ecbd5956d430b7465fa4baee97e8134903b25f43 GIT binary patch literal 1894 zcmYk7dpMN)7RP_@JKiySW-ua`F>0JTp+GGuRqatXPGDU&*dO{cI+oZ090ob}IVt@W&DJO2VQ#CtG zOx?awb6lhxJOLByG^nrorWWQFqM8eXBTUyHXeZe>H57fCidgP@WavmCsK7#${%!M#3FKzZAT2VY*FDW_`iMs zN9+YO8=TPW2Hy5|cSkUN2w=k=JIut)Zxfj>6fpbT*#I@k`?o2(5k`L89xThYuyg%y zhvQc&iRhmu7V`UwK~MeLonZ6CFN0%dWjAZ**~nv93`gBJPDV(jnO?_zG}u)KdpZVf z^`tw(GX~+j`mt&ce^MDVm-faSoGHf}1HaYyKr;!xQkS?>#2BLmo>TJ#BIrAdV&oC9 zHa~s|ta@5&z0Kg$Bv10~d%3KKm>6lh@)y>6kRE| ziw|V*^eEM6J@;@XNW^pNrF+p@6wfXo;#Tq8YSap?L_uS7!^*3%pI)DD(S-?Q8CS@| zI6}bmxQOd%Wmk9_VIGP5j*y&jDkNv1m3!%R+`F<}&LMULwrvun^$EP_Ro)TH<0)UZa#Lq|qlWQcHrp|z0iO}IRUSYIBf?CJK% z<~ig>W><6CJQ`=)<80A=x~0e6pGDiL;_{J^JHU)4ubMBJe-bF>fMnx@!AWnNXL3SB~Iqg*{U}EaZR@LTv)$h^^YhOnF zaEM*to_KwIeBHIZ-rx)HJrR}ZdxFuJ7a-$8tgz}bU`9&vHgm>$1jFm|QKR*y6TKdR zXe!bHoZ!lBAsRNmBwwnS3z#K*)<;GVLg>+h>Boet^)gH=PRwfOA; zD477|d@%d6D0#;8o#Jl$5&kpS^A^ZN9$^$Tj?ce5txF~VK1rJ_4wRC`)B1Iygs=Fd zJfbK_8RD7^oA%}eN&zGg}by7<@Sna=I z^qb;d(12+{CDM&tFk2)qLU#U%{%3CH`1qzV*n8-q!U!~XP#{G7(_s}-3a&tfz>sk; zp70Es3xvpZB7w1}#)~fJac&YEm;94}A!?VWm(ba-Agq zJ?RkK$B>198k9-@Im>;b^r{my1V{F;p=j&qEeKt(_PC%J=G0EodJ$khQ)V`y|F-Gr z#qyUgR|LC5CXT9V)O;FhV_CDtYvSWSkLG%Q1hVA$>0u(%_8}BMdY6TN*mpP1BT@5K zGEH94$}Rn16FtPbriayK{}8aUA|gmfX=i81oQsbSEt?JURP-iU3jm=Pi*FXb*|y!a z>MJb6aAqGQSGD$(Xi5J3owzqvb-X3bvITIjl@|>jVuxq{wIO(9K)DmgZ7F)*S5B@z z>F%Z(s#77mNSOpgi1}%TNOyrjWrfkQQ%^|!FFoo|k^yI0YsR=(>pfQxeKZyqK7uF; zFHYVco%?NIU=lkr+Zt!@^rc(H`t#RY(&v0d@cZ0P))sif7)#z8gkE%@)ns!xsSXzV z6n%cy7$FL|N}b*){WYqwzO!`4i4WhNUXzW>8amgLTxLd+_}o*|b_s8+p2pkLgAaFv zAFy=vI>k5}C-CoF*Ba4|y{_qDnULSt%|5Nevjl&vU_@Hb$KY>}eqxpt{T3V)2Kkb# zsPv!ENT@nY=#5K68f|dfb$HhF)52x=w$5v7<@p7pkP(TjF literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon32.png b/tidal_dl_ng/ui/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..7f57ff34502d05c229b379aa4526fe59a45962a0 GIT binary patch literal 670 zcmV;P0%84$P)b@?J+;z`cXb9?UdYtQLT1ptIM>d!mP zKLA_+j4=?AVKB!2J;1BQ4UoxX;P?9x2n3MJ<+uS%yoKd*8HGXt7K;V>e4ZQN|82pm z#N7e_2!%p0nV#C>{u_{G8KNi}uEk<8M59r|HBl5H%ko`-oA`>Npi-$|yubeg5sSqFHk%EZOa@M;^TNE_?ZV-3z-%_7TCLuMAfr$eg${=UousNNiK0l1F(M)g z27}aUwWw4oQ8Jk%A|l2ZiK0lVs_J!z!+{hh*ehou+9dNz!92_z7_` z2cQEOi7`e1bckP=-v$6cUawa-KLPanecJE$G#-y>yWLW~UZ?qdt^=GPzP`ThnH$f* zxu(-8cDvn0E}2YXFc_d#t09}s!sT+I(P&)Uv)k=3o6YWR&vz83QYjP)1^wD&GNIGy z@ND(?Q7i}od_Er}NrI}XaJ${8)oSo~JP-td3*v5{&g19T@xZk64dHJfG)*JF-_K*t z2XGGYq&W#E3LZ(41VIom8jX0jz*7LP(A)orJ^&wpZzKOty~EYx(EtDd07*qoM6N<$ Ef}h_kIsgCw literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon48.png b/tidal_dl_ng/ui/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..8d9ee596013d66ee3f05c0fbcfc3fb184efc43c1 GIT binary patch literal 1211 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZANS%G|oWRD45dJguM!v-tY$DUh!@P+6=(yLU`q0KcVYP7-hXC4kjGx) z>Fdh=l1ofPjk$TLrWFGN;}cI8#}JR>$q5py3I|lzGq8TLamg&$`}cvJgv}0NuPJQ* z0p%m3#U?#D0r z@$mn7hgsPi9~6BLumAMuzDq{J!{*E%|G#S6JlYXiuri%1YT20v!ONdME6*%k!C&#I zez}l^+_^RJ+uxqE-@vxB_y7EM3@IOUnLw{D!0P< z-23nA4Yr$qm-$=w$8!SP%Hyk=HD7REc^KVY_m}JIzpwlKpI@E-tMtD@(|N-`2e&gl zyId}uzETmM?a>b7pJ#zyEU&1pSg+6DJ#O&{$HF`-Fw<_5HiSYWjOVii>!?QINfHmAUlI z&r2s7;Vau5F|9`*rpN+3AJhoI#;Jb9CxkN@qWPgT3 z&@_{;ixW05Gc4{Ys(gL0%K?}KR7+eVN>UO_QmvAUQh^kMk%5t^uAzahkx__|ft9hj zm9eR|fq|8QfjMU%Fx4Sx$jwj5OsmAL!7aq>FHnOZ$cEI4%rq`R!22(?$ZF)QI zV5*5kQKPLGxeBO80c1{bep*R+Vo@qXMoCFQv6a4lW^Q77DiCKTXY1t`rSCZ}I~QhN zNJeRHl9iQ9esU?$#u6*50HD%h2E*n5ck836@dTM-W#y5YnVwO?U}$ON5L*}uR3i?u z6KW1rO?YNXNd`#CFVdQ&MBb@0Hg-h+W-In literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon512.png b/tidal_dl_ng/ui/icon512.png new file mode 100644 index 0000000000000000000000000000000000000000..6fd1fe79889e997fe991eab3d8c18c6cce35f3ed GIT binary patch literal 25668 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 literal 0 HcmV?d00001 diff --git a/tidal_dl_ng/ui/icon64.png b/tidal_dl_ng/ui/icon64.png new file mode 100644 index 0000000000000000000000000000000000000000..cb104814624df4b5b54b487c3044fefe36e1fec3 GIT binary patch literal 536 zcmV+z0_XjSP)IWHMS2mehgS`cBSpdK;4L4HT8FZp-D9q1Cy<}WHBIP1c=d}ID^j4 zzZdXT0P!3!7^6M~YIxWMwFlr0=Vm0lS|*U@md^{71O|+z>NWtR5;=kL)HDkW1fZ%Z zmH^I+jKFz*ZxR@Am6M@`J^^$7SXlC@hXjn2^^ zgI|HJwFG#zn~8ZoP1rp|Xm{@b2hlja)8|1S3L4ilP_lA-h^VvqouF}<0!9R@(-cE@4+k_<`;qU^Jkq)sP!hYkjb+!=b!EZ3AsE86aM;VQfk{qoZ$Vf zHo*%MI_2YS>PkK+y0xaa#OcBsKXsH9$N2`lo zp*I%z3?yPn2PSCYU7A8DIfMIV@jZ}Goe=W(HzO40H=#Jc2q*GMIF)DNT%LrJ`Kzw_ aH}wOffDva*^hJOG0000 + + MainWindow + + + + 0 + 0 + 1200 + 800 + + + + MainWindow + + + + true + + + + 100 + 100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + 10 + + + true + + + true + + + true + + + true + + + true + + + + Name + + + + + + + + + + + + + + obj + + + + + Info + + + + + + + + + + + + + + Playlists + + + + + + + + + ItemIsEnabled + + + + + Mixes + + + + + + + + + ItemIsEnabled + + + + + Favorites + + + + + + + + + ItemIsEnabled + + + + + + + + + + + 0 + 0 + + + + Reload + + + + + + + + 0 + 0 + + + + Download List + + + + + + + + + + + -1 + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + Type and press ENTER to search... + + + true + + + + + + + + 100 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow + + + + + + + + + + + + + + + + + + + + + + Search + + + + + + + + + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + false + + + false + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + 10 + + + true + + + + + + + + + + + + + + + + + + + + + + + + Audio + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 140 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow + + + + + + true + + + + + + + + + + + + + + + + + + + + + + Video + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 100 + 0 + + + + + + + + + + + + + + + + + + + + + + QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow + + + + + + + + + + + + + + + + + + + + + + + + + Download + + + + + + + + + + + + true + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + false + + + + + + + + + + + + + + + + + + false + + + true + + + Logs... + + + + + + + + + + + + + + + + 0 + 0 + + + + + 280 + 280 + + + + + 0 + 0 + + + + QFrame::Shape::NoFrame + + + + + + default_album_image.png + + + true + + + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop + + + + + + + + + + + + + + false + true + true + + + + Download Queue + + + + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + false + + + false + + + QAbstractItemView::SelectionMode::ExtendedSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + false + + + false + + + false + + + false + + + false + + + + 🧑‍💻️ + + + + + obj + + + + + Name + + + + + Type + + + + + Quality Audio + + + + + Quality Video + + + + + + + + + + true + + + Remove + + + + + + + + + + Queue + + + + + + + + + + + + + + Clear Finished + + + + + + + Clear All + + + + + + + + + + + + + + + 0 + 0 + 1200 + 24 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + File + + + + + + + + Help + + + + + + + + + + + + + + + + + + + + + + + + + Qt::LayoutDirection::LeftToRight + + + + + true + + + Preferences... + + + Preferences... + + + Preferences... + + + + + + + + + + + Version + + + + + Quit TIDAL-Downloader-NG + + + + + Logout + + + + + Check for Updates + + + + + + diff --git a/tidal_dl_ng/ui/spinner.py b/tidal_dl_ng/ui/spinner.py new file mode 100644 index 0000000..124b7f4 --- /dev/null +++ b/tidal_dl_ng/ui/spinner.py @@ -0,0 +1,221 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PySide6.QtCore import QRect, Qt, QTimer +from PySide6.QtGui import QColor, QPainter +from PySide6.QtWidgets import QWidget + + +# Taken from https://github.com/COOLMSF/QtWaitingSpinnerForPyQt6 and adapted for PySide6. +class QtWaitingSpinner(QWidget): + def __init__( + self, parent, centerOnParent=True, disableParentWhenSpinning=False, modality=Qt.WindowModality.NonModal + ): + super().__init__(parent) + + self._centerOnParent = centerOnParent + self._disableParentWhenSpinning = disableParentWhenSpinning + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 10 + self._lineWidth = 2 + self._innerRadius = 10 + self._currentCounter = 0 + self._isSpinning = False + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + self.hide() + # END initialize() + + self.setWindowModality(modality) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + self.updatePosition() + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + # Can't found in Qt6 + # painter.setRenderHint(QPainter.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) + color = self.currentLineColor( + distance, self._numberOfLines, self._trailFadePercentage, self._minimumTrailOpacity, self._color + ) + painter.setBrush(color) + rect = QRect(0, int(-self._lineWidth / 2), int(self._lineLength), int(self._lineWidth)) + painter.drawRoundedRect(rect, self._roundness, self._roundness, Qt.SizeMode.RelativeSize) + painter.restore() + + def start(self): + self.updatePosition() + self._isSpinning = True + self.show() + + if self.parentWidget and self._disableParentWhenSpinning: + self.parentWidget().setEnabled(False) + + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + self._isSpinning = False + self.hide() + + if self.parentWidget() and self._disableParentWhenSpinning: + self.parentWidget().setEnabled(True) + + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def isSpinning(self): + return self._isSpinning + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + size = int((self._innerRadius + self._lineLength) * 2) + self.setFixedSize(size, size) + + def updateTimer(self): + self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond))) + + def updatePosition(self): + if self.parentWidget() and self._centerOnParent: + self.move( + int(self.parentWidget().width() / 2 - self.width() / 2), + int(self.parentWidget().height() / 2 - self.height() / 2), + ) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color diff --git a/tidal_dl_ng/worker.py b/tidal_dl_ng/worker.py new file mode 100644 index 0000000..a02b0f1 --- /dev/null +++ b/tidal_dl_ng/worker.py @@ -0,0 +1,34 @@ +from PySide6 import QtCore + + +# Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/ +class Worker(QtCore.QRunnable): + """ + Worker thread + + Inherits from QRunnable to handler worker thread setup, signals and wrap-up. + + :param callback: The function callback to run on this worker thread. Supplied args and + kwargs will be passed through to the runner. + :type callback: function + :param args: Arguments to pass to the callback function + :param kwargs: Keywords to pass to the callback function + + """ + + def __init__(self, fn, *args, **kwargs): + super().__init__() + # Store constructor arguments (re-used for processing) + self.fn = fn + self.args = args + self.kwargs = kwargs + + @QtCore.Slot() # QtCore.Slot + def run(self): + """ + Initialise the runner function with passed args, kwargs. + """ + self.fn(*self.args, **self.kwargs) + + def thread(self) -> QtCore.QThread: + return QtCore.QThread.currentThread()