update
49
.gitignore
vendored
Normal file
@@ -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
|
||||||
27
Dockerfile
Normal file
@@ -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"]
|
||||||
66
README.md
Normal file
@@ -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 <repository-url>
|
||||||
|
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).
|
||||||
23
app/main.py
Normal file
@@ -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})
|
||||||
28
app/routers/auth.py
Normal file
@@ -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"})
|
||||||
40
app/routers/download.py
Normal file
@@ -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")
|
||||||
14
app/routers/search.py
Normal file
@@ -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))
|
||||||
17
app/routers/system.py
Normal file
@@ -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)}
|
||||||
309
app/services/download_manager.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
161
app/services/tidal_wrapper.py
Normal file
@@ -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)
|
||||||
123
app/static/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
200
app/templates/index.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tidal DL Web</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Tidal DL Web</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Search</h2>
|
||||||
|
<input type="text" id="query" placeholder="Search for tracks, albums..." onkeypress="handleEnter(event)">
|
||||||
|
<select id="type"
|
||||||
|
style="padding: 10px; background: #2d2d2d; color: white; border: 1px solid #333; border-radius: 4px;">
|
||||||
|
<option value="track">Track</option>
|
||||||
|
<option value="album">Album</option>
|
||||||
|
<option value="artist">Artist</option>
|
||||||
|
<option value="playlist">Playlist</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="search()">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results" class="card" style="display:none;">
|
||||||
|
<h2>Results</h2>
|
||||||
|
<div id="results-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Download Queue</h2>
|
||||||
|
<div id="queue-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="card" style="margin-top: 20px; text-align: center; color: #888;">
|
||||||
|
<small>System IP: <span id="system-ip">Loading...</span></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function checkIp() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/system/ip');
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('system-ip').textContent = data.ip;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('system-ip').textContent = 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkIp();
|
||||||
|
|
||||||
|
function handleEnter(e) {
|
||||||
|
if (e.key === 'Enter') search();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const query = document.getElementById('query').value;
|
||||||
|
const type = document.getElementById('type').value;
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/search/?query=${encodeURIComponent(query)}&type=${type}`);
|
||||||
|
const results = await response.json();
|
||||||
|
|
||||||
|
const list = document.getElementById('results-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
document.getElementById('results').style.display = 'block';
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
list.innerHTML = '<p>No results found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouping Logic
|
||||||
|
let grouped = {};
|
||||||
|
let groupKey = '';
|
||||||
|
|
||||||
|
if (type === 'track') {
|
||||||
|
groupKey = 'album'; // Group tracks by Album
|
||||||
|
} else if (type === 'album') {
|
||||||
|
groupKey = 'artist'; // Group albums by Artist
|
||||||
|
} else {
|
||||||
|
// No grouping for artist/playlist
|
||||||
|
renderFlatList(results, list);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.forEach(item => {
|
||||||
|
const key = item[groupKey] || 'Unknown';
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [groupName, items] of Object.entries(grouped)) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.open = true; // Open by default
|
||||||
|
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = `${groupName} (${items.length})`;
|
||||||
|
details.appendChild(summary);
|
||||||
|
|
||||||
|
const groupList = document.createElement('div');
|
||||||
|
groupList.className = 'group-list';
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const div = createResultItem(item);
|
||||||
|
groupList.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
details.appendChild(groupList);
|
||||||
|
list.appendChild(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlatList(items, container) {
|
||||||
|
items.forEach(item => {
|
||||||
|
const div = createResultItem(item);
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResultItem(item) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'result-item';
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${item.cover || '/static/placeholder.png'}" alt="Cover">
|
||||||
|
<div class="result-info">
|
||||||
|
<strong>${item.title}</strong><br>
|
||||||
|
${item.artist ? item.artist + ' - ' : ''}${item.album || ''}
|
||||||
|
</div>
|
||||||
|
<button onclick="download('${item.type}', '${item.id}')">Download</button>
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(type, id) {
|
||||||
|
await fetch('/download/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type, id })
|
||||||
|
});
|
||||||
|
updateQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateQueue() {
|
||||||
|
const response = await fetch('/download/queue');
|
||||||
|
const queue = await response.json();
|
||||||
|
|
||||||
|
const list = document.getElementById('queue-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
queue.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'queue-item';
|
||||||
|
let controls = '';
|
||||||
|
if (item.status === 'downloading' || item.status === 'queued') {
|
||||||
|
if (item.control === 'pause') {
|
||||||
|
controls += `<button onclick="controlTask('${item.id}', 'resume')">Resume</button>`;
|
||||||
|
} else {
|
||||||
|
controls += `<button onclick="controlTask('${item.id}', 'pause')">Pause</button>`;
|
||||||
|
}
|
||||||
|
controls += `<button onclick="controlTask('${item.id}', 'cancel')" style="background-color: #ff4444;">Cancel</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<strong>${item.name}</strong><br>
|
||||||
|
${item.type}
|
||||||
|
${item.current_item ? '<br><small>' + item.current_item + '</small>' : ''}
|
||||||
|
${item.total_items ? '<br><small>Track ' + item.current_index + ' of ' + item.total_items + '</small>' : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<div class="status-${item.status}">
|
||||||
|
${item.status} ${item.progress ? Math.round(item.progress) + '%' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
${controls}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
list.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function controlTask(id, action) {
|
||||||
|
await fetch(`/download/${action}/${id}`, { method: 'POST' });
|
||||||
|
updateQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll queue
|
||||||
|
setInterval(updateQueue, 2000);
|
||||||
|
updateQueue();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
61
app/templates/login.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tidal DL - Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Tidal DL Web</h1>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Login Required</h2>
|
||||||
|
<p>Please login to your Tidal account to continue.</p>
|
||||||
|
<div id="login-step-1">
|
||||||
|
<button onclick="startLogin()">Start Device Login</button>
|
||||||
|
</div>
|
||||||
|
<div id="login-step-2" style="display:none;">
|
||||||
|
<p id="login-message">Initializing...</p>
|
||||||
|
<p>Link: <a id="login-link" href="#" target="_blank">Open Tidal Login</a></p>
|
||||||
|
<p>Code: <strong id="login-code"></strong></p>
|
||||||
|
<div id="spinner">Waiting for you to login...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function startLogin() {
|
||||||
|
document.getElementById('login-step-1').style.display = 'none';
|
||||||
|
document.getElementById('login-step-2').style.display = 'block';
|
||||||
|
|
||||||
|
const response = await fetch('/auth/start', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
updateStatus(data);
|
||||||
|
|
||||||
|
// Poll status
|
||||||
|
setInterval(checkStatus, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
const response = await fetch('/auth/status');
|
||||||
|
const data = await response.json();
|
||||||
|
updateStatus(data);
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(data) {
|
||||||
|
document.getElementById('login-message').innerText = data.message;
|
||||||
|
if (data.link) {
|
||||||
|
document.getElementById('login-link').href = data.link;
|
||||||
|
document.getElementById('login-link').innerText = data.link;
|
||||||
|
}
|
||||||
|
// If code is part of message or separate, handle it.
|
||||||
|
// Our backend might not parse code perfectly yet, but message usually contains it.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
docker-compose.yml
Normal file
@@ -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
|
||||||
17
requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
requests
|
||||||
|
mutagen
|
||||||
|
dataclasses-json
|
||||||
|
pathvalidate
|
||||||
|
m3u8
|
||||||
|
coloredlogs
|
||||||
|
rich
|
||||||
|
toml
|
||||||
|
typer
|
||||||
|
tidalapi
|
||||||
|
python-ffmpeg
|
||||||
|
pycryptodome
|
||||||
|
ansi2html
|
||||||
4
run.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8002, reload=True)
|
||||||
1
tidal-dl-ng-source
Submodule
145
tidal_dl_ng/__init__.py
Normal file
@@ -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
|
||||||
121
tidal_dl_ng/api.py
Normal file
@@ -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
|
||||||
536
tidal_dl_ng/cli.py
Normal file
@@ -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 <URL>`
|
||||||
|
sys.argv.insert(1, "dl")
|
||||||
|
|
||||||
|
app()
|
||||||
293
tidal_dl_ng/config.py
Normal file
@@ -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()
|
||||||
97
tidal_dl_ng/constants.py
Normal file
@@ -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"},
|
||||||
|
}
|
||||||
360
tidal_dl_ng/dialog.py
Normal file
@@ -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'<a href="https://{url_login}">https://{url_login}</a>')
|
||||||
|
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()
|
||||||
1787
tidal_dl_ng/download.py
Normal file
2521
tidal_dl_ng/gui.py
Normal file
0
tidal_dl_ng/helper/__init__.py
Normal file
22
tidal_dl_ng/helper/decorator.py
Normal file
@@ -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]
|
||||||
63
tidal_dl_ng/helper/decryption.py
Normal file
@@ -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)
|
||||||
14
tidal_dl_ng/helper/exceptions.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
class LoginError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUnknown(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownManifestFormat(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MediaMissing(Exception):
|
||||||
|
pass
|
||||||
225
tidal_dl_ng/helper/gui.py
Normal file
@@ -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)
|
||||||
691
tidal_dl_ng/helper/path.py
Normal file
@@ -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
|
||||||
274
tidal_dl_ng/helper/tidal.py
Normal file
@@ -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
|
||||||
26
tidal_dl_ng/helper/wrapper.py
Normal file
@@ -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)
|
||||||
65
tidal_dl_ng/logger.py
Normal file
@@ -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)
|
||||||
206
tidal_dl_ng/metadata.py
Normal file
@@ -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]
|
||||||
0
tidal_dl_ng/model/__init__.py
Normal file
145
tidal_dl_ng/model/cfg.py
Normal file
@@ -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
|
||||||
24
tidal_dl_ng/model/downloader.py
Normal file
@@ -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
|
||||||
50
tidal_dl_ng/model/gui_data.py
Normal file
@@ -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
|
||||||
14
tidal_dl_ng/model/meta.py
Normal file
@@ -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
|
||||||
0
tidal_dl_ng/ui/__init__.py
Normal file
BIN
tidal_dl_ng/ui/default_album_image.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
119
tidal_dl_ng/ui/dialog_login.py
Normal file
@@ -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
|
||||||
195
tidal_dl_ng/ui/dialog_login.ui
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DialogLogin</class>
|
||||||
|
<widget class="QDialog" name="DialogLogin">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>451</width>
|
||||||
|
<height>400</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Dialog</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QDialogButtonBox" name="bb_dialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>20</x>
|
||||||
|
<y>350</y>
|
||||||
|
<width>411</width>
|
||||||
|
<height>32</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="layoutDirection">
|
||||||
|
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||||
|
</property>
|
||||||
|
<property name="styleSheet">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<widget class="QWidget" name="verticalLayoutWidget">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>20</x>
|
||||||
|
<y>20</y>
|
||||||
|
<width>411</width>
|
||||||
|
<height>325</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="lv_main">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_header">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>23</pointsize>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TIDAL Login (as Device)</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_description">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<italic>true</italic>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>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.</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTextBrowser" name="tb_url_login">
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string>Copy this login URL...</string>
|
||||||
|
</property>
|
||||||
|
<property name="openExternalLinks">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_expires_description">
|
||||||
|
<property name="text">
|
||||||
|
<string>This link expires at:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_expires_date_time">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>COMPUTING</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_hint">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<italic>true</italic>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Waiting...</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>bb_dialog</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>DialogLogin</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>bb_dialog</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>DialogLogin</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
||||||
660
tidal_dl_ng/ui/dialog_settings.py
Normal file
@@ -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
|
||||||
846
tidal_dl_ng/ui/dialog_settings.ui
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DialogSettings</class>
|
||||||
|
<widget class="QDialog" name="DialogSettings">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>640</width>
|
||||||
|
<height>832</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Preferences</string>
|
||||||
|
</property>
|
||||||
|
<property name="sizeGripEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="lv_dialog_settings">
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_main">
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>12</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="gb_flags">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="title">
|
||||||
|
<string>Flags</string>
|
||||||
|
</property>
|
||||||
|
<property name="flat">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="checkable">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flags">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_flags_1">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_video_download">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_video_download">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_video_convert">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_video_convert_mp4">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_flags_2">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_lyrics_embed">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_lyrics_embed">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_lyrics_file">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_lyrics_file">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_flag_3">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_download_delay">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_download_delay">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_extract_flac">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_extract_flac">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_flags_4">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_metadata_cover_embed">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_metadata_cover_embed">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_cover_album_file">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_cover_album_file">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_flag_skip_existing">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_skip_existing">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_symlink_to_track">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_symlink_to_track">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_12">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_playlist_create">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_playlist_create">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_mark_explicit">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_mark_explicit">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_13">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_use_primary_album_artist">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_use_primary_album_artist">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_download_dolby_atmos">
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="cb_download_dolby_atmos">
|
||||||
|
<property name="text">
|
||||||
|
<string>CheckBox</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="gb_choices">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="title">
|
||||||
|
<string>Choices</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="lv_choices">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_choices_quality_audio" stretch="0,0,50">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_quality_audio">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_quality_audio">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="c_quality_audio"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_choices_quality_video" stretch="0,0,50">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_quality_video">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_quality_video">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="c_quality_video"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_choices_cover_dimension" stretch="0,0,50">
|
||||||
|
<property name="sizeConstraint">
|
||||||
|
<enum>QLayout::SizeConstraint::SetDefaultConstraint</enum>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_metadata_cover_dimension">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_metadata_cover_dimension">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="c_metadata_cover_dimension">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>10</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="gb_numbers">
|
||||||
|
<property name="title">
|
||||||
|
<string>Numbers</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_8">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_9">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_album_track_num_pad_min">
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_album_track_num_pad_min">
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QSpinBox" name="sb_album_track_num_pad_min">
|
||||||
|
<property name="maximum">
|
||||||
|
<number>4</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_11">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_downloads_concurrent_max">
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_downloads_concurrent_max">
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QSpinBox" name="sb_downloads_concurrent_max">
|
||||||
|
<property name="minimum">
|
||||||
|
<number>1</number>
|
||||||
|
</property>
|
||||||
|
<property name="maximum">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="gb_path">
|
||||||
|
<property name="title">
|
||||||
|
<string>Path</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_2" stretch="0,50">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_base">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_download_base_path">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_download_base_path">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_fmt_track">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_format_track">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_format_track">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_fmt_video">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_format_video">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_format_video">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_fmt_album">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_format_album">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_format_album">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_fpath_mt_playlist">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_format_playlist">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_format_playlist">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_fmt_mix">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_format_mix">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_format_mix">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_path_binary_ffmpeg">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_icon_path_binary_ffmpeg">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_path_binary_ffmpeg">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TextLabel</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_10">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_download_base_path">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="dragEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_download_base_path">
|
||||||
|
<property name="text">
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_7">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_format_track"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_format_video"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_format_album"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_format_playlist">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_8">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_format_mix"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="le_path_binary_ffmpeg">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="dragEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_path_binary_ffmpeg">
|
||||||
|
<property name="text">
|
||||||
|
<string>...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="bb_dialog">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>bb_dialog</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>DialogSettings</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>319</x>
|
||||||
|
<y>661</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>319</x>
|
||||||
|
<y>340</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>bb_dialog</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>DialogSettings</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>319</x>
|
||||||
|
<y>661</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>319</x>
|
||||||
|
<y>340</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
||||||
161
tidal_dl_ng/ui/dialog_version.py
Normal file
@@ -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", "<ERROR>", 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", "<CHANGELOG>", None))
|
||||||
|
self.pb_download.setText(QCoreApplication.translate("DialogVersion", "Download", None))
|
||||||
|
self.l_url_github.setText(
|
||||||
|
QCoreApplication.translate(
|
||||||
|
"DialogVersion",
|
||||||
|
'<a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a>',
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# retranslateUi
|
||||||
227
tidal_dl_ng/ui/dialog_version.ui
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>DialogVersion</class>
|
||||||
|
<widget class="QDialog" name="DialogVersion">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>436</width>
|
||||||
|
<height>235</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>436</width>
|
||||||
|
<height>235</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Version</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_name_app">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>TIDAL Downloader Next Generation!</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_version">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_version">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_h_version">
|
||||||
|
<property name="text">
|
||||||
|
<string>Installed Version:</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_version">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>v1.2.3</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_update">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_error">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>ERROR</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_error_details">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string><ERROR></string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignCenter</set>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_update_version">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_h_version_new">
|
||||||
|
<property name="text">
|
||||||
|
<string>New Version Available:</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_version_new">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>v0.0.0</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_changelog">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Changelog</string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_changelog_details">
|
||||||
|
<property name="text">
|
||||||
|
<string><CHANGELOG></string>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lv_download">
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>20</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_download">
|
||||||
|
<property name="text">
|
||||||
|
<string>Download</string>
|
||||||
|
</property>
|
||||||
|
<property name="flat">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="sh_download">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_url_github">
|
||||||
|
<property name="text">
|
||||||
|
<string><a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a></string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
<property name="openExternalLinks">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="textInteractionFlags">
|
||||||
|
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
33
tidal_dl_ng/ui/dummy_register.py
Normal file
@@ -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 = """
|
||||||
|
<ui language='c++'>
|
||||||
|
<widget class='WigglyWidget' name='wigglyWidget'>
|
||||||
|
<property name='geometry'>
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>400</width>
|
||||||
|
<height>200</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name='text'>
|
||||||
|
<string>Hello, world</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</ui>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
QPyDesignerCustomWidgetCollection.registerCustomWidget(
|
||||||
|
WigglyWidget, module="wigglywidget", tool_tip=TOOLTIP, xml=DOM_XML
|
||||||
|
)
|
||||||
68
tidal_dl_ng/ui/dummy_wiggly.py
Normal file
@@ -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)
|
||||||
BIN
tidal_dl_ng/ui/icon.icns
Normal file
BIN
tidal_dl_ng/ui/icon.ico
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
tidal_dl_ng/ui/icon16.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
tidal_dl_ng/ui/icon256.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
tidal_dl_ng/ui/icon32.png
Normal file
|
After Width: | Height: | Size: 670 B |
BIN
tidal_dl_ng/ui/icon48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
tidal_dl_ng/ui/icon512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
tidal_dl_ng/ui/icon64.png
Normal file
|
After Width: | Height: | Size: 536 B |
607
tidal_dl_ng/ui/main.py
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'main.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.9.0
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt
|
||||||
|
from PySide6.QtGui import QAction, QFont, QIcon, QPixmap
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QComboBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMenu,
|
||||||
|
QMenuBar,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QStatusBar,
|
||||||
|
QTreeView,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Ui_MainWindow:
|
||||||
|
def setupUi(self, MainWindow):
|
||||||
|
if not MainWindow.objectName():
|
||||||
|
MainWindow.setObjectName("MainWindow")
|
||||||
|
MainWindow.resize(1200, 800)
|
||||||
|
self.a_preferences = QAction(MainWindow)
|
||||||
|
self.a_preferences.setObjectName("a_preferences")
|
||||||
|
self.a_preferences.setEnabled(True)
|
||||||
|
self.a_preferences.setText("Preferences...")
|
||||||
|
self.a_preferences.setIconText("Preferences...")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.a_preferences.setToolTip("Preferences...")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.a_preferences.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.a_preferences.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
self.a_version = QAction(MainWindow)
|
||||||
|
self.a_version.setObjectName("a_version")
|
||||||
|
self.a_exit = QAction(MainWindow)
|
||||||
|
self.a_exit.setObjectName("a_exit")
|
||||||
|
self.a_logout = QAction(MainWindow)
|
||||||
|
self.a_logout.setObjectName("a_logout")
|
||||||
|
self.a_updates_check = QAction(MainWindow)
|
||||||
|
self.a_updates_check.setObjectName("a_updates_check")
|
||||||
|
self.w_central = QWidget(MainWindow)
|
||||||
|
self.w_central.setObjectName("w_central")
|
||||||
|
self.w_central.setEnabled(True)
|
||||||
|
sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||||
|
sizePolicy.setHorizontalStretch(100)
|
||||||
|
sizePolicy.setVerticalStretch(100)
|
||||||
|
sizePolicy.setHeightForWidth(self.w_central.sizePolicy().hasHeightForWidth())
|
||||||
|
self.w_central.setSizePolicy(sizePolicy)
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.w_central.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.w_central.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.w_central.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.w_central.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.w_central.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.horizontalLayout = QHBoxLayout(self.w_central)
|
||||||
|
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||||
|
self.lv_list_user = QVBoxLayout()
|
||||||
|
self.lv_list_user.setObjectName("lv_list_user")
|
||||||
|
self.tr_lists_user = QTreeWidget(self.w_central)
|
||||||
|
__qtreewidgetitem = QTreeWidgetItem()
|
||||||
|
__qtreewidgetitem.setText(2, "Info")
|
||||||
|
__qtreewidgetitem.setText(0, "Name")
|
||||||
|
self.tr_lists_user.setHeaderItem(__qtreewidgetitem)
|
||||||
|
__qtreewidgetitem1 = QTreeWidgetItem(self.tr_lists_user)
|
||||||
|
__qtreewidgetitem1.setFlags(Qt.ItemIsEnabled)
|
||||||
|
__qtreewidgetitem2 = QTreeWidgetItem(self.tr_lists_user)
|
||||||
|
__qtreewidgetitem2.setFlags(Qt.ItemIsEnabled)
|
||||||
|
__qtreewidgetitem3 = QTreeWidgetItem(self.tr_lists_user)
|
||||||
|
__qtreewidgetitem3.setFlags(Qt.ItemIsEnabled)
|
||||||
|
self.tr_lists_user.setObjectName("tr_lists_user")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.tr_lists_user.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.tr_lists_user.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.tr_lists_user.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.tr_lists_user.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.tr_lists_user.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.tr_lists_user.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.tr_lists_user.setProperty("showDropIndicator", False)
|
||||||
|
self.tr_lists_user.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
self.tr_lists_user.setIndentation(10)
|
||||||
|
self.tr_lists_user.setUniformRowHeights(True)
|
||||||
|
self.tr_lists_user.setSortingEnabled(True)
|
||||||
|
self.tr_lists_user.header().setCascadingSectionResizes(True)
|
||||||
|
self.tr_lists_user.header().setHighlightSections(True)
|
||||||
|
self.tr_lists_user.header().setProperty("showSortIndicator", True)
|
||||||
|
|
||||||
|
self.lv_list_user.addWidget(self.tr_lists_user)
|
||||||
|
|
||||||
|
self.lv_list_control = QHBoxLayout()
|
||||||
|
self.lv_list_control.setObjectName("lv_list_control")
|
||||||
|
self.pb_reload_user_lists = QPushButton(self.w_central)
|
||||||
|
self.pb_reload_user_lists.setObjectName("pb_reload_user_lists")
|
||||||
|
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
|
sizePolicy1.setHorizontalStretch(0)
|
||||||
|
sizePolicy1.setVerticalStretch(0)
|
||||||
|
sizePolicy1.setHeightForWidth(self.pb_reload_user_lists.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_reload_user_lists.setSizePolicy(sizePolicy1)
|
||||||
|
|
||||||
|
self.lv_list_control.addWidget(self.pb_reload_user_lists)
|
||||||
|
|
||||||
|
self.pb_download_list = QPushButton(self.w_central)
|
||||||
|
self.pb_download_list.setObjectName("pb_download_list")
|
||||||
|
sizePolicy1.setHeightForWidth(self.pb_download_list.sizePolicy().hasHeightForWidth())
|
||||||
|
self.pb_download_list.setSizePolicy(sizePolicy1)
|
||||||
|
|
||||||
|
self.lv_list_control.addWidget(self.pb_download_list)
|
||||||
|
|
||||||
|
self.lv_list_user.addLayout(self.lv_list_control)
|
||||||
|
|
||||||
|
self.horizontalLayout.addLayout(self.lv_list_user)
|
||||||
|
|
||||||
|
self.lv_search_result = QVBoxLayout()
|
||||||
|
# ifndef Q_OS_MAC
|
||||||
|
self.lv_search_result.setSpacing(-1)
|
||||||
|
# endif
|
||||||
|
self.lv_search_result.setObjectName("lv_search_result")
|
||||||
|
self.lh_search = QHBoxLayout()
|
||||||
|
self.lh_search.setObjectName("lh_search")
|
||||||
|
self.l_search = QLineEdit(self.w_central)
|
||||||
|
self.l_search.setObjectName("l_search")
|
||||||
|
self.l_search.setAcceptDrops(False)
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.l_search.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.l_search.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.l_search.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_search.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_search.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.l_search.setLocale(QLocale(QLocale.English, QLocale.UnitedStates))
|
||||||
|
self.l_search.setText("")
|
||||||
|
self.l_search.setPlaceholderText("Type and press ENTER to search...")
|
||||||
|
self.l_search.setClearButtonEnabled(True)
|
||||||
|
|
||||||
|
self.lh_search.addWidget(self.l_search)
|
||||||
|
|
||||||
|
self.cb_search_type = QComboBox(self.w_central)
|
||||||
|
self.cb_search_type.setObjectName("cb_search_type")
|
||||||
|
self.cb_search_type.setMinimumSize(QSize(100, 0))
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.cb_search_type.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.cb_search_type.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.cb_search_type.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_search_type.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_search_type.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.cb_search_type.setCurrentText("")
|
||||||
|
self.cb_search_type.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
|
||||||
|
self.cb_search_type.setPlaceholderText("")
|
||||||
|
|
||||||
|
self.lh_search.addWidget(self.cb_search_type)
|
||||||
|
|
||||||
|
self.pb_search = QPushButton(self.w_central)
|
||||||
|
self.pb_search.setObjectName("pb_search")
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.pb_search.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.pb_search.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.pb_search.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.pb_search.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.pb_search.setText("Search")
|
||||||
|
# if QT_CONFIG(shortcut)
|
||||||
|
self.pb_search.setShortcut("")
|
||||||
|
# endif // QT_CONFIG(shortcut)
|
||||||
|
|
||||||
|
self.lh_search.addWidget(self.pb_search)
|
||||||
|
|
||||||
|
self.lv_search_result.addLayout(self.lh_search)
|
||||||
|
|
||||||
|
self.tr_results = QTreeView(self.w_central)
|
||||||
|
self.tr_results.setObjectName("tr_results")
|
||||||
|
self.tr_results.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.tr_results.setProperty("showDropIndicator", False)
|
||||||
|
self.tr_results.setDragDropOverwriteMode(False)
|
||||||
|
self.tr_results.setAlternatingRowColors(False)
|
||||||
|
self.tr_results.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
self.tr_results.setIndentation(10)
|
||||||
|
self.tr_results.setSortingEnabled(True)
|
||||||
|
|
||||||
|
self.lv_search_result.addWidget(self.tr_results)
|
||||||
|
|
||||||
|
self.lh_download = QHBoxLayout()
|
||||||
|
self.lh_download.setObjectName("lh_download")
|
||||||
|
self.l_quality_audio = QLabel(self.w_central)
|
||||||
|
self.l_quality_audio.setObjectName("l_quality_audio")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.l_quality_audio.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.l_quality_audio.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.l_quality_audio.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_audio.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_audio.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_audio.setText("Audio")
|
||||||
|
self.l_quality_audio.setAlignment(
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lh_download.addWidget(self.l_quality_audio)
|
||||||
|
|
||||||
|
self.cb_quality_audio = QComboBox(self.w_central)
|
||||||
|
self.cb_quality_audio.setObjectName("cb_quality_audio")
|
||||||
|
self.cb_quality_audio.setMinimumSize(QSize(140, 0))
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.cb_quality_audio.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.cb_quality_audio.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.cb_quality_audio.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_audio.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_audio.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_audio.setCurrentText("")
|
||||||
|
self.cb_quality_audio.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
|
||||||
|
self.cb_quality_audio.setPlaceholderText("")
|
||||||
|
self.cb_quality_audio.setFrame(True)
|
||||||
|
|
||||||
|
self.lh_download.addWidget(self.cb_quality_audio)
|
||||||
|
|
||||||
|
self.l_quality_video = QLabel(self.w_central)
|
||||||
|
self.l_quality_video.setObjectName("l_quality_video")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.l_quality_video.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.l_quality_video.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.l_quality_video.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_video.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_video.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.l_quality_video.setText("Video")
|
||||||
|
self.l_quality_video.setAlignment(
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lh_download.addWidget(self.l_quality_video)
|
||||||
|
|
||||||
|
self.cb_quality_video = QComboBox(self.w_central)
|
||||||
|
self.cb_quality_video.setObjectName("cb_quality_video")
|
||||||
|
self.cb_quality_video.setMinimumSize(QSize(100, 0))
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.cb_quality_video.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.cb_quality_video.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.cb_quality_video.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_video.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_video.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.cb_quality_video.setCurrentText("")
|
||||||
|
self.cb_quality_video.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContentsOnFirstShow)
|
||||||
|
self.cb_quality_video.setPlaceholderText("")
|
||||||
|
|
||||||
|
self.lh_download.addWidget(self.cb_quality_video)
|
||||||
|
|
||||||
|
self.pb_download = QPushButton(self.w_central)
|
||||||
|
self.pb_download.setObjectName("pb_download")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.pb_download.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.pb_download.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.pb_download.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.pb_download.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.pb_download.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.pb_download.setText("Download")
|
||||||
|
# if QT_CONFIG(shortcut)
|
||||||
|
self.pb_download.setShortcut("")
|
||||||
|
# endif // QT_CONFIG(shortcut)
|
||||||
|
|
||||||
|
self.lh_download.addWidget(self.pb_download)
|
||||||
|
|
||||||
|
self.lh_download.setStretch(0, 5)
|
||||||
|
self.lh_download.setStretch(2, 5)
|
||||||
|
self.lh_download.setStretch(4, 15)
|
||||||
|
|
||||||
|
self.lv_search_result.addLayout(self.lh_download)
|
||||||
|
|
||||||
|
self.te_debug = QPlainTextEdit(self.w_central)
|
||||||
|
self.te_debug.setObjectName("te_debug")
|
||||||
|
self.te_debug.setEnabled(True)
|
||||||
|
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
|
||||||
|
sizePolicy2.setHorizontalStretch(0)
|
||||||
|
sizePolicy2.setVerticalStretch(0)
|
||||||
|
sizePolicy2.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth())
|
||||||
|
self.te_debug.setSizePolicy(sizePolicy2)
|
||||||
|
self.te_debug.setMaximumSize(QSize(16777215, 16777215))
|
||||||
|
self.te_debug.setAcceptDrops(False)
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.te_debug.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.te_debug.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.te_debug.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.te_debug.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.te_debug.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.te_debug.setUndoRedoEnabled(False)
|
||||||
|
self.te_debug.setReadOnly(True)
|
||||||
|
|
||||||
|
self.lv_search_result.addWidget(self.te_debug)
|
||||||
|
|
||||||
|
self.horizontalLayout.addLayout(self.lv_search_result)
|
||||||
|
|
||||||
|
self.lv_info = QVBoxLayout()
|
||||||
|
self.lv_info.setObjectName("lv_info")
|
||||||
|
self.lv_info_item = QVBoxLayout()
|
||||||
|
self.lv_info_item.setObjectName("lv_info_item")
|
||||||
|
self.horizontalLayout_2 = QHBoxLayout()
|
||||||
|
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||||
|
self.l_pm_cover = QLabel(self.w_central)
|
||||||
|
self.l_pm_cover.setObjectName("l_pm_cover")
|
||||||
|
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||||
|
sizePolicy3.setHorizontalStretch(0)
|
||||||
|
sizePolicy3.setVerticalStretch(0)
|
||||||
|
sizePolicy3.setHeightForWidth(self.l_pm_cover.sizePolicy().hasHeightForWidth())
|
||||||
|
self.l_pm_cover.setSizePolicy(sizePolicy3)
|
||||||
|
self.l_pm_cover.setMaximumSize(QSize(280, 280))
|
||||||
|
self.l_pm_cover.setBaseSize(QSize(0, 0))
|
||||||
|
self.l_pm_cover.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
self.l_pm_cover.setPixmap(QPixmap("default_album_image.png"))
|
||||||
|
self.l_pm_cover.setScaledContents(True)
|
||||||
|
self.l_pm_cover.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
self.horizontalLayout_2.addWidget(self.l_pm_cover)
|
||||||
|
|
||||||
|
self.lv_info_item.addLayout(self.horizontalLayout_2)
|
||||||
|
|
||||||
|
self.lv_info.addLayout(self.lv_info_item)
|
||||||
|
|
||||||
|
self.lv_queue_download = QVBoxLayout()
|
||||||
|
self.lv_queue_download.setObjectName("lv_queue_download")
|
||||||
|
self.l_h_queue_download = QLabel(self.w_central)
|
||||||
|
self.l_h_queue_download.setObjectName("l_h_queue_download")
|
||||||
|
font = QFont()
|
||||||
|
font.setBold(True)
|
||||||
|
font.setItalic(False)
|
||||||
|
font.setKerning(True)
|
||||||
|
self.l_h_queue_download.setFont(font)
|
||||||
|
|
||||||
|
self.lv_queue_download.addWidget(self.l_h_queue_download)
|
||||||
|
|
||||||
|
self.tr_queue_download = QTreeWidget(self.w_central)
|
||||||
|
__qtreewidgetitem4 = QTreeWidgetItem()
|
||||||
|
__qtreewidgetitem4.setText(0, "\ud83e\uddd1\u200d\ud83d\udcbb\ufe0f")
|
||||||
|
self.tr_queue_download.setHeaderItem(__qtreewidgetitem4)
|
||||||
|
self.tr_queue_download.setObjectName("tr_queue_download")
|
||||||
|
self.tr_queue_download.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
self.tr_queue_download.setTabKeyNavigation(False)
|
||||||
|
self.tr_queue_download.setProperty("showDropIndicator", False)
|
||||||
|
self.tr_queue_download.setDragDropOverwriteMode(False)
|
||||||
|
self.tr_queue_download.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
|
self.tr_queue_download.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self.tr_queue_download.setRootIsDecorated(False)
|
||||||
|
self.tr_queue_download.setItemsExpandable(False)
|
||||||
|
self.tr_queue_download.setSortingEnabled(False)
|
||||||
|
self.tr_queue_download.setExpandsOnDoubleClick(False)
|
||||||
|
self.tr_queue_download.header().setProperty("showSortIndicator", False)
|
||||||
|
self.tr_queue_download.header().setStretchLastSection(False)
|
||||||
|
|
||||||
|
self.lv_queue_download.addWidget(self.tr_queue_download)
|
||||||
|
|
||||||
|
self.horizontalLayout_4 = QHBoxLayout()
|
||||||
|
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
|
||||||
|
self.pb_queue_download_remove = QPushButton(self.w_central)
|
||||||
|
self.pb_queue_download_remove.setObjectName("pb_queue_download_remove")
|
||||||
|
self.pb_queue_download_remove.setEnabled(True)
|
||||||
|
icon = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.EditClear))
|
||||||
|
self.pb_queue_download_remove.setIcon(icon)
|
||||||
|
|
||||||
|
self.horizontalLayout_4.addWidget(self.pb_queue_download_remove)
|
||||||
|
|
||||||
|
self.pb_queue_download_toggle = QPushButton(self.w_central)
|
||||||
|
self.pb_queue_download_toggle.setObjectName("pb_queue_download_toggle")
|
||||||
|
icon1 = QIcon(QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackPause))
|
||||||
|
self.pb_queue_download_toggle.setIcon(icon1)
|
||||||
|
|
||||||
|
self.horizontalLayout_4.addWidget(self.pb_queue_download_toggle)
|
||||||
|
|
||||||
|
self.lv_queue_download.addLayout(self.horizontalLayout_4)
|
||||||
|
|
||||||
|
self.horizontalLayout_3 = QHBoxLayout()
|
||||||
|
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||||
|
self.pb_queue_download_clear_finished = QPushButton(self.w_central)
|
||||||
|
self.pb_queue_download_clear_finished.setObjectName("pb_queue_download_clear_finished")
|
||||||
|
|
||||||
|
self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_finished)
|
||||||
|
|
||||||
|
self.pb_queue_download_clear_all = QPushButton(self.w_central)
|
||||||
|
self.pb_queue_download_clear_all.setObjectName("pb_queue_download_clear_all")
|
||||||
|
|
||||||
|
self.horizontalLayout_3.addWidget(self.pb_queue_download_clear_all)
|
||||||
|
|
||||||
|
self.lv_queue_download.addLayout(self.horizontalLayout_3)
|
||||||
|
|
||||||
|
self.lv_info.addLayout(self.lv_queue_download)
|
||||||
|
|
||||||
|
self.horizontalLayout.addLayout(self.lv_info)
|
||||||
|
|
||||||
|
self.horizontalLayout.setStretch(1, 50)
|
||||||
|
self.horizontalLayout.setStretch(2, 25)
|
||||||
|
MainWindow.setCentralWidget(self.w_central)
|
||||||
|
self.menubar = QMenuBar(MainWindow)
|
||||||
|
self.menubar.setObjectName("menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 1200, 24))
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.menubar.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.menubar.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.menubar.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.menubar.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.menubar.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.m_file = QMenu(self.menubar)
|
||||||
|
self.m_file.setObjectName("m_file")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.m_file.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.m_file.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.m_file.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.m_file.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.m_file.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.m_help = QMenu(self.menubar)
|
||||||
|
self.m_help.setObjectName("m_help")
|
||||||
|
MainWindow.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(MainWindow)
|
||||||
|
self.statusbar.setObjectName("statusbar")
|
||||||
|
# if QT_CONFIG(tooltip)
|
||||||
|
self.statusbar.setToolTip("")
|
||||||
|
# endif // QT_CONFIG(tooltip)
|
||||||
|
# if QT_CONFIG(statustip)
|
||||||
|
self.statusbar.setStatusTip("")
|
||||||
|
# endif // QT_CONFIG(statustip)
|
||||||
|
# if QT_CONFIG(whatsthis)
|
||||||
|
self.statusbar.setWhatsThis("")
|
||||||
|
# endif // QT_CONFIG(whatsthis)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.statusbar.setAccessibleName("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
# if QT_CONFIG(accessibility)
|
||||||
|
self.statusbar.setAccessibleDescription("")
|
||||||
|
# endif // QT_CONFIG(accessibility)
|
||||||
|
self.statusbar.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
|
||||||
|
MainWindow.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.menubar.addAction(self.m_file.menuAction())
|
||||||
|
self.menubar.addAction(self.m_help.menuAction())
|
||||||
|
self.m_file.addAction(self.a_preferences)
|
||||||
|
self.m_file.addAction(self.a_logout)
|
||||||
|
self.m_file.addAction(self.a_exit)
|
||||||
|
self.m_help.addAction(self.a_version)
|
||||||
|
self.m_help.addAction(self.a_updates_check)
|
||||||
|
|
||||||
|
self.retranslateUi(MainWindow)
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, MainWindow):
|
||||||
|
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None))
|
||||||
|
self.a_version.setText(QCoreApplication.translate("MainWindow", "Version", None))
|
||||||
|
self.a_exit.setText(QCoreApplication.translate("MainWindow", "Quit TIDAL-Downloader-NG", None))
|
||||||
|
self.a_logout.setText(QCoreApplication.translate("MainWindow", "Logout", None))
|
||||||
|
self.a_updates_check.setText(QCoreApplication.translate("MainWindow", "Check for Updates", None))
|
||||||
|
___qtreewidgetitem = self.tr_lists_user.headerItem()
|
||||||
|
___qtreewidgetitem.setText(1, QCoreApplication.translate("MainWindow", "obj", None))
|
||||||
|
|
||||||
|
__sortingEnabled = self.tr_lists_user.isSortingEnabled()
|
||||||
|
self.tr_lists_user.setSortingEnabled(False)
|
||||||
|
___qtreewidgetitem1 = self.tr_lists_user.topLevelItem(0)
|
||||||
|
___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", "Playlists", None))
|
||||||
|
___qtreewidgetitem2 = self.tr_lists_user.topLevelItem(1)
|
||||||
|
___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", "Mixes", None))
|
||||||
|
___qtreewidgetitem3 = self.tr_lists_user.topLevelItem(2)
|
||||||
|
___qtreewidgetitem3.setText(0, QCoreApplication.translate("MainWindow", "Favorites", None))
|
||||||
|
self.tr_lists_user.setSortingEnabled(__sortingEnabled)
|
||||||
|
|
||||||
|
self.pb_reload_user_lists.setText(QCoreApplication.translate("MainWindow", "Reload", None))
|
||||||
|
self.pb_download_list.setText(QCoreApplication.translate("MainWindow", "Download List", None))
|
||||||
|
self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
|
||||||
|
self.l_pm_cover.setText("")
|
||||||
|
self.l_h_queue_download.setText(QCoreApplication.translate("MainWindow", "Download Queue", None))
|
||||||
|
___qtreewidgetitem4 = self.tr_queue_download.headerItem()
|
||||||
|
___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "Quality Video", None))
|
||||||
|
___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Quality Audio", None))
|
||||||
|
___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Type", None))
|
||||||
|
___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Name", None))
|
||||||
|
___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "obj", None))
|
||||||
|
self.pb_queue_download_remove.setText(QCoreApplication.translate("MainWindow", "Remove", None))
|
||||||
|
self.pb_queue_download_toggle.setText(QCoreApplication.translate("MainWindow", "Queue", None))
|
||||||
|
self.pb_queue_download_clear_finished.setText(QCoreApplication.translate("MainWindow", "Clear Finished", None))
|
||||||
|
self.pb_queue_download_clear_all.setText(QCoreApplication.translate("MainWindow", "Clear All", None))
|
||||||
|
self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None))
|
||||||
|
self.m_help.setTitle(QCoreApplication.translate("MainWindow", "Help", None))
|
||||||
|
|
||||||
|
# retranslateUi
|
||||||
823
tidal_dl_ng/ui/main.ui
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>MainWindow</class>
|
||||||
|
<widget class="QMainWindow" name="MainWindow">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>1200</width>
|
||||||
|
<height>800</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>MainWindow</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="w_central">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||||
|
<horstretch>100</horstretch>
|
||||||
|
<verstretch>100</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,50,25">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_list_user">
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeWidget" name="tr_lists_user">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="editTriggers">
|
||||||
|
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
|
||||||
|
</property>
|
||||||
|
<property name="showDropIndicator" stdset="0">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
|
||||||
|
</property>
|
||||||
|
<property name="indentation">
|
||||||
|
<number>10</number>
|
||||||
|
</property>
|
||||||
|
<property name="uniformRowHeights">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<attribute name="headerCascadingSectionResizes">
|
||||||
|
<bool>true</bool>
|
||||||
|
</attribute>
|
||||||
|
<attribute name="headerHighlightSections">
|
||||||
|
<bool>true</bool>
|
||||||
|
</attribute>
|
||||||
|
<attribute name="headerShowSortIndicator" stdset="0">
|
||||||
|
<bool>true</bool>
|
||||||
|
</attribute>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Name</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>obj</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Info</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Playlists</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="flags">
|
||||||
|
<set>ItemIsEnabled</set>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Mixes</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="flags">
|
||||||
|
<set>ItemIsEnabled</set>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>Favorites</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="flags">
|
||||||
|
<set>ItemIsEnabled</set>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lv_list_control">
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_reload_user_lists">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Reload</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_download_list">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Download List</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>-1</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_search">
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="l_search">
|
||||||
|
<property name="acceptDrops">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="locale">
|
||||||
|
<locale language="English" country="UnitedStates"/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string notr="true">Type and press ENTER to search...</string>
|
||||||
|
</property>
|
||||||
|
<property name="clearButtonEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="cb_search_type">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="currentText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="sizeAdjustPolicy">
|
||||||
|
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_search">
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Search</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeView" name="tr_results">
|
||||||
|
<property name="editTriggers">
|
||||||
|
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
|
||||||
|
</property>
|
||||||
|
<property name="showDropIndicator" stdset="0">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="dragDropOverwriteMode">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="alternatingRowColors">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
|
||||||
|
</property>
|
||||||
|
<property name="indentation">
|
||||||
|
<number>10</number>
|
||||||
|
</property>
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="lh_download" stretch="5,0,5,0,15">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_quality_audio">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Audio</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="cb_quality_audio">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>140</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="currentText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="sizeAdjustPolicy">
|
||||||
|
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="frame">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_quality_video">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Video</string>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="cb_quality_video">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="currentText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="sizeAdjustPolicy">
|
||||||
|
<enum>QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow</enum>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_download">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Download</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPlainTextEdit" name="te_debug">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Expanding" vsizetype="Maximum">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>16777215</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="acceptDrops">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="undoRedoEnabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="readOnly">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string>Logs...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_info">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_info_item">
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_pm_cover">
|
||||||
|
<property name="sizePolicy">
|
||||||
|
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
|
||||||
|
<horstretch>0</horstretch>
|
||||||
|
<verstretch>0</verstretch>
|
||||||
|
</sizepolicy>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>280</width>
|
||||||
|
<height>280</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="baseSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="pixmap">
|
||||||
|
<pixmap>default_album_image.png</pixmap>
|
||||||
|
</property>
|
||||||
|
<property name="scaledContents">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="lv_queue_download">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="l_h_queue_download">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<italic>false</italic>
|
||||||
|
<bold>true</bold>
|
||||||
|
<kerning>true</kerning>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Download Queue</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QTreeWidget" name="tr_queue_download">
|
||||||
|
<property name="editTriggers">
|
||||||
|
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
|
||||||
|
</property>
|
||||||
|
<property name="tabKeyNavigation">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="showDropIndicator" stdset="0">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="dragDropOverwriteMode">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
|
||||||
|
</property>
|
||||||
|
<property name="selectionBehavior">
|
||||||
|
<enum>QAbstractItemView::SelectionBehavior::SelectRows</enum>
|
||||||
|
</property>
|
||||||
|
<property name="rootIsDecorated">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="itemsExpandable">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="sortingEnabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="expandsOnDoubleClick">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<attribute name="headerShowSortIndicator" stdset="0">
|
||||||
|
<bool>false</bool>
|
||||||
|
</attribute>
|
||||||
|
<attribute name="headerStretchLastSection">
|
||||||
|
<bool>false</bool>
|
||||||
|
</attribute>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">🧑💻️</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>obj</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>Name</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>Type</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>Quality Audio</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
<column>
|
||||||
|
<property name="text">
|
||||||
|
<string>Quality Video</string>
|
||||||
|
</property>
|
||||||
|
</column>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_queue_download_remove">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Remove</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="QIcon::ThemeIcon::EditClear"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_queue_download_toggle">
|
||||||
|
<property name="text">
|
||||||
|
<string>Queue</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="QIcon::ThemeIcon::MediaPlaybackPause"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_queue_download_clear_finished">
|
||||||
|
<property name="text">
|
||||||
|
<string>Clear Finished</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="pb_queue_download_clear_all">
|
||||||
|
<property name="text">
|
||||||
|
<string>Clear All</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QMenuBar" name="menubar">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>1200</width>
|
||||||
|
<height>24</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<widget class="QMenu" name="m_file">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="title">
|
||||||
|
<string>File</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="a_preferences"/>
|
||||||
|
<addaction name="a_logout"/>
|
||||||
|
<addaction name="a_exit"/>
|
||||||
|
</widget>
|
||||||
|
<widget class="QMenu" name="m_help">
|
||||||
|
<property name="title">
|
||||||
|
<string>Help</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="a_version"/>
|
||||||
|
<addaction name="a_updates_check"/>
|
||||||
|
</widget>
|
||||||
|
<addaction name="m_file"/>
|
||||||
|
<addaction name="m_help"/>
|
||||||
|
</widget>
|
||||||
|
<widget class="QStatusBar" name="statusbar">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleName">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="accessibleDescription">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="layoutDirection">
|
||||||
|
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<action name="a_preferences">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string notr="true">Preferences...</string>
|
||||||
|
</property>
|
||||||
|
<property name="iconText">
|
||||||
|
<string notr="true">Preferences...</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string notr="true">Preferences...</string>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
<property name="whatsThis">
|
||||||
|
<string notr="true"/>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="a_version">
|
||||||
|
<property name="text">
|
||||||
|
<string>Version</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="a_exit">
|
||||||
|
<property name="text">
|
||||||
|
<string>Quit TIDAL-Downloader-NG</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="a_logout">
|
||||||
|
<property name="text">
|
||||||
|
<string>Logout</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="a_updates_check">
|
||||||
|
<property name="text">
|
||||||
|
<string>Check for Updates</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
221
tidal_dl_ng/ui/spinner.py
Normal file
@@ -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
|
||||||
34
tidal_dl_ng/worker.py
Normal file
@@ -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()
|
||||||