Merge branch 'master' into nuitka
This commit is contained in:
@@ -16,7 +16,6 @@ on:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
RELEASE_UPLOAD_URL: "release_upload_url"
|
||||
ASSET_EXTENSION: ".zip"
|
||||
OUT_NAME_FILE: "TIDAL-Downloader-NG"
|
||||
ARCH_MACOS_X64: "macos-x64"
|
||||
@@ -46,18 +45,11 @@ jobs:
|
||||
POETRY_REQUESTS_TIMEOUT: 120
|
||||
- name: GitHub Release -- Create
|
||||
id: release_create
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: GitHub Release -- Output Release URL File
|
||||
run: echo "${{steps.release_create.outputs.upload_url}}" > ${{env.RELEASE_UPLOAD_URL}}.txt
|
||||
- name: GitHub Release -- Save Upload URL File for publish
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{env.RELEASE_UPLOAD_URL}}
|
||||
path: ${{env.RELEASE_UPLOAD_URL}}.txt
|
||||
deploy-docs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -90,8 +82,6 @@ jobs:
|
||||
echo "ARCH_WINDOWS_X64=${{ env.ARCH_WINDOWS_X64 }}" >> $GITHUB_OUTPUT
|
||||
build-gui-and-upload-assets:
|
||||
needs: ["publish", "compute"]
|
||||
env:
|
||||
ASSET_MIME: "application/zip"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -144,23 +134,11 @@ jobs:
|
||||
uses: ./.github/actions/setup-poetry-env
|
||||
- name: Build with pyinstaller for ${{matrix.TARGET}}
|
||||
run: ${{matrix.CMD_BUILD}}
|
||||
- name: Load Release URL File from release job
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{env.RELEASE_UPLOAD_URL}}
|
||||
- name: Get Release File Name & Upload URL
|
||||
id: get_release_info
|
||||
run: |
|
||||
VALUE=`cat ${{env.RELEASE_UPLOAD_URL}}.txt`
|
||||
echo "upload_url=$VALUE" >> $GITHUB_OUTPUT
|
||||
- name: Upload Release Asset
|
||||
# TODO: Switch to https://github.com/softprops/action-gh-release
|
||||
id: upload-release-asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
with:
|
||||
upload_url: ${{steps.get_release_info.outputs.upload_url}}
|
||||
asset_path: ./dist/${{needs.compute.outputs.OUT_NAME_FILE}}_${{matrix.OS_ARCH}}${{needs.compute.outputs.ASSET_EXTENSION}}
|
||||
asset_name: ${{needs.compute.outputs.OUT_NAME_FILE}}_${{matrix.OS_ARCH}}${{needs.compute.outputs.ASSET_EXTENSION}}
|
||||
asset_content_type: ${{env.ASSET_MIME}}
|
||||
files: |
|
||||
./dist/${{needs.compute.outputs.OUT_NAME_FILE}}_${{matrix.OS_ARCH}}${{needs.compute.outputs.ASSET_EXTENSION}}
|
||||
|
||||
@@ -9,7 +9,7 @@ This tool allows to download songs and videos from TIDAL. Multithreaded and mult
|
||||
|
||||
⚠️ **Windows** Defender / **Anti Virus** software / web browser alerts, while you try to download the app binary: This is a **false positive**. Please read [this issue](https://github.com/exislow/tidal-dl-ng/issues/231), [PyInstaller (used by this project) statement ](https://github.com/pyinstaller/pyinstaller/blob/develop/.github/ISSUE_TEMPLATE/antivirus.md) and [the alternative installation solution](https://github.com/exislow/tidal-dl-ng/?tab=readme-ov-file#-installation--upgrade). ⚠️
|
||||
|
||||
**A paid plan is required!** Audio quality varies up to HiRes Lossless / TIDAL MAX 24-bit, 192 kHz depending on the song available. You can use the command line or GUI version of this tool.
|
||||
**A paid TIDAL plan is required!** Audio quality varies up to HiRes Lossless / TIDAL MAX 24-bit, 192 kHz depending on the song available. You can use the command line or GUI version of this tool.
|
||||
|
||||

|
||||
|
||||
@@ -28,27 +28,31 @@ $ tidal-dl-ng --help
|
||||
│ for this option. To set a value for an option simply pass the value │
|
||||
│ as the second argument │
|
||||
│ dl │
|
||||
│ dl_fav Download from a favorites collection. │
|
||||
│ gui │
|
||||
│ login │
|
||||
│ logout │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
If you like this projects and want to support it, you can buy me a coffee :-)
|
||||
If you like this projects and want to support it, feel free to buy me a coffee 🙃✌️
|
||||
|
||||
<a href="https://www.buymeacoffee.com/exislow" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/arial-orange.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a>
|
||||
<a href="https://ko-fi.com/exislow" target="_blank" rel="noopener noreferrer"><img src="https://help.ko-fi.com/hc/article_attachments/11833788361117" alt="61e11d430afb112ea33c3aa5_Button-1-p-500"></a>
|
||||
|
||||
## 💻 Installation / Upgrade
|
||||
|
||||
**Requirements**: Python >= 3.12 (other versions might work but are not tested!)
|
||||
**Requirements**: Python == 3.12 (other versions might work but are not tested!)
|
||||
|
||||
```bash
|
||||
pip install --upgrade tidal-dl-ng
|
||||
# AND if you like to have the GUI as well
|
||||
# If you like to have the GUI as well use this command instead
|
||||
pip install --upgrade tidal-dl-ng[gui]
|
||||
```
|
||||
|
||||
You can use the command line (CLI) version to download media:
|
||||
## ⌨️ Usage
|
||||
|
||||
You can use the command line (CLI) version to download media by URL:
|
||||
|
||||
```bash
|
||||
tidal-dl-ng dl https://tidal.com/browse/track/46755209
|
||||
@@ -56,7 +60,16 @@ tidal-dl-ng dl https://tidal.com/browse/track/46755209
|
||||
tdn dl https://tidal.com/browse/track/46755209
|
||||
```
|
||||
|
||||
But also the GUI:
|
||||
Or by your favorites collections:
|
||||
|
||||
```bash
|
||||
tidal-dl-ng dl_fav tracks
|
||||
tidal-dl-ng dl_fav artists
|
||||
tidal-dl-ng dl_fav albums
|
||||
tidal-dl-ng dl_fav videos
|
||||
```
|
||||
|
||||
You can also use the GUI:
|
||||
|
||||
```bash
|
||||
tidal-dl-ng-gui
|
||||
@@ -71,7 +84,7 @@ If you like to have the GUI version only as a binary, have a look at the
|
||||
|
||||
## 🧁 Features
|
||||
|
||||
- Download Tracks, Videos, Albums, Playlists etc.
|
||||
- Download tracks, videos, albums, playlists, your favorites etc.
|
||||
- Multithreaded and multi-chunked downloads
|
||||
- Metadata for songs
|
||||
- Adjustable audio and video download quality.
|
||||
@@ -105,20 +118,26 @@ The GUI is build with `PySide6` using the [Qt Designer](https://doc.qt.io/qt-6/q
|
||||
PYSIDE_DESIGNER_PLUGINS=tidal_dl_ng/ui pyside6-designer
|
||||
```
|
||||
|
||||
After all changes are saved you need to translate the Qt Designer `*.ui` file into Python code:
|
||||
After all changes are saved you need to translate the Qt Designer `*.ui` file into Python code, for instance:
|
||||
|
||||
```
|
||||
pyside6-uic tidal_dl_ng/ui/main.ui -o tidal_dl_ng/ui/main.py
|
||||
```
|
||||
|
||||
This needs to be done for each created / modified `*.ui` file accordingly.
|
||||
|
||||
### 🏗 Build the project
|
||||
|
||||
To build the project use this command:
|
||||
|
||||
```bash
|
||||
make install
|
||||
# OR
|
||||
make gui-macos
|
||||
```
|
||||
|
||||
See the `Makefile` for all available build commands.
|
||||
|
||||
The CI/CD pipeline will be triggered when you open a pull request, merge to main, or when you create a new release.
|
||||
|
||||
To finalize the set-up for publishing to PyPi or Artifactory, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/publishing/#set-up-for-pypi).
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[tool.poetry]
|
||||
name = "tidal-dl-ng"
|
||||
authors = ["Robert Honz <cen.sored@gmail.com>"]
|
||||
version = "0.23.5"
|
||||
version = "0.23.8"
|
||||
description = "TIDAL Medial Downloader Next Generation!"
|
||||
repository = "https://github.com/exislow/tidal-dl-ng"
|
||||
documentation = "https://exislow.github.io/tidal-dl-ng/"
|
||||
|
||||
+187
-71
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -23,6 +24,13 @@ 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)
|
||||
dl_fav_group = typer.Typer(
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
add_completion=True,
|
||||
help="Download from a favorites collection.",
|
||||
)
|
||||
|
||||
app.add_typer(dl_fav_group, name="dl_fav")
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
@@ -31,6 +39,92 @@ def version_callback(value: bool):
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bool:
|
||||
"""Invokes download function and tracks progress.
|
||||
|
||||
:param ctx: The typer context object.
|
||||
:type ctx: typer.Context
|
||||
:param urls: The list of URLs to download.
|
||||
:type urls: list[str]
|
||||
:param try_login: If true, attempts to login to TIDAL.
|
||||
:type try_login: bool
|
||||
:return: True if ran successfully.
|
||||
:rtype: bool
|
||||
"""
|
||||
if try_login:
|
||||
# Call login method to validate the token.
|
||||
ctx.invoke(login, ctx)
|
||||
|
||||
# Create initial objects.
|
||||
settings: Settings = Settings()
|
||||
progress: Progress = Progress(
|
||||
"{task.description}",
|
||||
SpinnerColumn(),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
)
|
||||
fn_logger = LoggerWrapped(progress.print)
|
||||
dl = Download(
|
||||
session=ctx.obj[CTX_TIDAL].session,
|
||||
skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing,
|
||||
path_base=settings.data.download_base_path,
|
||||
fn_logger=fn_logger,
|
||||
progress=progress,
|
||||
)
|
||||
progress_table = Table.grid()
|
||||
|
||||
# Style Progress display.
|
||||
progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)))
|
||||
|
||||
urls_pos_last = len(urls) - 1
|
||||
|
||||
for item in urls:
|
||||
media_type: MediaType | bool = False
|
||||
|
||||
# Extract media name and id from link.
|
||||
if "http" in item:
|
||||
media_type = get_tidal_media_type(item)
|
||||
item_id = get_tidal_media_id(item)
|
||||
file_template = get_format_template(media_type, settings)
|
||||
else:
|
||||
print(f"It seems like that you have supplied an invalid URL: {item}")
|
||||
|
||||
continue
|
||||
|
||||
# Create Live display for Progress.
|
||||
with Live(progress_table, refresh_per_second=10):
|
||||
# Download media.
|
||||
if media_type in [MediaType.TRACK, MediaType.VIDEO]:
|
||||
download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last)
|
||||
|
||||
dl.item(
|
||||
media_id=item_id, media_type=media_type, file_template=file_template, download_delay=download_delay
|
||||
)
|
||||
elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]:
|
||||
item_ids: [int] = []
|
||||
|
||||
if media_type == MediaType.ARTIST:
|
||||
media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id)
|
||||
media_type = MediaType.ALBUM
|
||||
item_ids = item_ids + all_artist_album_ids(media)
|
||||
else:
|
||||
item_ids.append(item_id)
|
||||
|
||||
for item_id in item_ids:
|
||||
dl.items(
|
||||
media_id=item_id,
|
||||
media_type=media_type,
|
||||
file_template=file_template,
|
||||
video_download=ctx.obj[CTX_TIDAL].settings.data.video_download,
|
||||
download_delay=settings.data.download_delay,
|
||||
)
|
||||
|
||||
# Stop Progress display.
|
||||
progress.stop()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@app.callback()
|
||||
def callback_app(
|
||||
ctx: typer.Context,
|
||||
@@ -54,6 +148,7 @@ def settings_management(
|
||||
If only one argument is given, the value will be printed for this option.
|
||||
To set a value for an option simply pass the value as the second argument
|
||||
|
||||
:param editor: If set, your favorite system editor will be opened.
|
||||
:param names: (Optional) None (list all options), one (list the value only for this option) or two arguments
|
||||
(set the value for the option).
|
||||
"""
|
||||
@@ -136,90 +231,111 @@ def download(
|
||||
help="List with URLs to download. One per line",
|
||||
),
|
||||
] = None,
|
||||
):
|
||||
) -> bool:
|
||||
if not urls:
|
||||
# Read the text file provided.
|
||||
if file_urls:
|
||||
text = file_urls.read_text()
|
||||
text: str = file_urls.read_text()
|
||||
urls = text.splitlines()
|
||||
else:
|
||||
print("Provide either URLs, IDs or a file containing URLs (one per line).")
|
||||
|
||||
raise typer.Abort()
|
||||
|
||||
return _download(ctx, urls)
|
||||
|
||||
|
||||
@dl_fav_group.command(
|
||||
name="tracks",
|
||||
help="Download your favorite track collection.",
|
||||
)
|
||||
def download_fav_tracks(ctx: typer.Context) -> bool:
|
||||
"""Download your favorite track collection.
|
||||
|
||||
:param ctx: Typer context object.
|
||||
:type ctx: typer.Context
|
||||
:return: Download result.
|
||||
:rtype: bool
|
||||
"""
|
||||
# Method name
|
||||
func_name_favorites: str = "tracks"
|
||||
|
||||
return _download_fav_factory(ctx, func_name_favorites)
|
||||
|
||||
|
||||
@dl_fav_group.command(
|
||||
name="artists",
|
||||
help="Download your favorite artist collection.",
|
||||
)
|
||||
def download_fav_artists(ctx: typer.Context) -> bool:
|
||||
"""Download your favorite artist collection.
|
||||
|
||||
:param ctx: Typer context object.
|
||||
:type ctx: typer.Context
|
||||
:return: Download result.
|
||||
:rtype: bool
|
||||
"""
|
||||
# Method name
|
||||
func_name_favorites: str = "artists"
|
||||
|
||||
return _download_fav_factory(ctx, func_name_favorites)
|
||||
|
||||
|
||||
@dl_fav_group.command(
|
||||
name="albums",
|
||||
help="Download your favorite album collection.",
|
||||
)
|
||||
def download_fav_albums(ctx: typer.Context) -> bool:
|
||||
"""Download your favorite album collection.
|
||||
|
||||
:param ctx: Typer context object.
|
||||
:type ctx: typer.Context
|
||||
:return: Download result.
|
||||
:rtype: bool
|
||||
"""
|
||||
# Method name
|
||||
func_name_favorites: str = "albums"
|
||||
|
||||
return _download_fav_factory(ctx, func_name_favorites)
|
||||
|
||||
|
||||
@dl_fav_group.command(
|
||||
name="videos",
|
||||
help="Download your favorite video collection.",
|
||||
)
|
||||
def download_fav_videos(ctx: typer.Context) -> bool:
|
||||
"""Download your favorite video collection.
|
||||
|
||||
:param ctx: Typer context object.
|
||||
:type ctx: typer.Context
|
||||
:return: Download result.
|
||||
:rtype: bool
|
||||
"""
|
||||
# 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.
|
||||
|
||||
:param ctx: Typer context object.
|
||||
:type ctx: typer.Context
|
||||
:param func_name_favorites: Method name to call from `tidalapi` favorites object.
|
||||
:type func_name_favorites: str
|
||||
:return: Download result.
|
||||
:rtype: bool
|
||||
"""
|
||||
# Call login method to validate the token.
|
||||
ctx.invoke(login, ctx)
|
||||
|
||||
# Create initial objects.
|
||||
settings: Settings = Settings()
|
||||
progress: Progress = Progress(
|
||||
"{task.description}",
|
||||
SpinnerColumn(),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
)
|
||||
fn_logger = LoggerWrapped(progress.print)
|
||||
dl = Download(
|
||||
session=ctx.obj[CTX_TIDAL].session,
|
||||
skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing,
|
||||
path_base=settings.data.download_base_path,
|
||||
fn_logger=fn_logger,
|
||||
progress=progress,
|
||||
)
|
||||
progress_table = Table.grid()
|
||||
# Get the method from the module
|
||||
func_favorites: Callable = getattr(ctx.obj[CTX_TIDAL].session.user.favorites, func_name_favorites)
|
||||
# Get favorite videos
|
||||
media_urls: [str] = [media.share_url for media in func_favorites()]
|
||||
|
||||
# Style Progress display.
|
||||
progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)))
|
||||
|
||||
urls_pos_last = len(urls) - 1
|
||||
|
||||
for item in urls:
|
||||
media_type: MediaType | bool = False
|
||||
|
||||
# Extract media name and id from link.
|
||||
if "http" in item:
|
||||
media_type = get_tidal_media_type(item)
|
||||
item_id = get_tidal_media_id(item)
|
||||
file_template = get_format_template(media_type, settings)
|
||||
|
||||
# If url is invalid skip to next url in list.
|
||||
if not media_type:
|
||||
print(f"It seems like that you have supplied an invalid URL: {item}")
|
||||
|
||||
continue
|
||||
|
||||
# Create Live display for Progress.
|
||||
with Live(progress_table, refresh_per_second=10):
|
||||
# Download media.
|
||||
if media_type in [MediaType.TRACK, MediaType.VIDEO]:
|
||||
download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last)
|
||||
|
||||
dl.item(
|
||||
media_id=item_id, media_type=media_type, file_template=file_template, download_delay=download_delay
|
||||
)
|
||||
elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]:
|
||||
item_ids: [int] = []
|
||||
|
||||
if media_type == MediaType.ARTIST:
|
||||
media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id)
|
||||
media_type = MediaType.ALBUM
|
||||
item_ids = item_ids + all_artist_album_ids(media)
|
||||
else:
|
||||
item_ids.append(item_id)
|
||||
|
||||
for item_id in item_ids:
|
||||
dl.items(
|
||||
media_id=item_id,
|
||||
media_type=media_type,
|
||||
file_template=file_template,
|
||||
video_download=ctx.obj[CTX_TIDAL].settings.data.video_download,
|
||||
download_delay=settings.data.download_delay,
|
||||
)
|
||||
|
||||
# Stop Progress display.
|
||||
progress.stop()
|
||||
|
||||
return True
|
||||
return _download(ctx, media_urls, try_login=False)
|
||||
|
||||
|
||||
@app.command()
|
||||
|
||||
@@ -8,9 +8,9 @@ from collections.abc import Callable
|
||||
from concurrent import futures
|
||||
from uuid import uuid4
|
||||
|
||||
from ffmpeg import FFmpeg
|
||||
import m3u8
|
||||
import requests
|
||||
from ffmpeg import FFmpeg
|
||||
from requests.adapters import HTTPAdapter, Retry
|
||||
from requests.exceptions import HTTPError
|
||||
from rich.progress import Progress, TaskID
|
||||
@@ -495,8 +495,10 @@ class Download:
|
||||
|
||||
if not skip_symlink:
|
||||
self.fn_logger.debug(f"Symlink: {path_media_src} -> {path_media_dst}")
|
||||
path_media_dst_relative: pathlib.Path = path_media_dst.relative_to(path_media_src.parent, walk_up=True)
|
||||
|
||||
path_media_src.unlink(missing_ok=True)
|
||||
path_media_src.symlink_to(path_media_dst)
|
||||
path_media_src.symlink_to(path_media_dst_relative)
|
||||
|
||||
return path_media_dst
|
||||
|
||||
@@ -779,11 +781,12 @@ class Download:
|
||||
FFmpeg(executable=self.settings.data.path_binary_ffmpeg)
|
||||
.input(url=path_media_src)
|
||||
.output(
|
||||
url=path_media_out, map=0,
|
||||
url=path_media_out,
|
||||
map=0,
|
||||
movflags="use_metadata_tags",
|
||||
acodec="copy",
|
||||
map_metadata="0:g",
|
||||
loglevel="quiet"
|
||||
loglevel="quiet",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ try:
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
except ImportError as e:
|
||||
print(e)
|
||||
print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'")
|
||||
print("Qt dependencies missing. Cannot start GUI. Please read the 'README.md' carefully.")
|
||||
sys.exit(1)
|
||||
|
||||
import coloredlogs.converter
|
||||
|
||||
Reference in New Issue
Block a user