diff --git a/.github/workflows/on-release-master.yml b/.github/workflows/on-release-master.yml
index 61215d7..f17056d 100644
--- a/.github/workflows/on-release-master.yml
+++ b/.github/workflows/on-release-master.yml
@@ -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}}
diff --git a/README.md b/README.md
index c820b3b..12abd78 100644
--- a/README.md
+++ b/README.md
@@ -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 🙃✌️
## 💻 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).
diff --git a/pyproject.toml b/pyproject.toml
index d886171..9db97b8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[tool.poetry]
name = "tidal-dl-ng"
authors = ["Robert Honz "]
-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/"
diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py
index 5abda11..54cbb5e 100644
--- a/tidal_dl_ng/cli.py
+++ b/tidal_dl_ng/cli.py
@@ -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()
diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py
index 6175e4c..0df6355 100644
--- a/tidal_dl_ng/download.py
+++ b/tidal_dl_ng/download.py
@@ -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",
)
)
diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py
index 2c4c382..0d20758 100644
--- a/tidal_dl_ng/gui.py
+++ b/tidal_dl_ng/gui.py
@@ -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