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. ![App Image](assets/app.png) @@ -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 🙃✌️ Buy Me A Coffee 61e11d430afb112ea33c3aa5_Button-1-p-500 ## 💻 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