Compare commits

...

29 Commits

Author SHA1 Message Date
Oskar Dudziński 0f44f9780a 🚀 bump to 2.6.2 2025-10-02 17:54:49 +02:00
Oskar Dudziński 5d420eeec5 🐛 Fixed M3U config option (#161) 2025-10-02 17:52:46 +02:00
Oskar Dudziński 3053e91134 🚀 bump to 2.6.2a1 2025-10-02 00:17:18 +02:00
Oskar Dudziński 36daea61e0 Fix m3u file saving (#160) 2025-10-02 00:15:39 +02:00
Oskar Dudziński 12a2d4cf5f Added mix downloading 2025-09-29 19:45:05 +02:00
Oskar Dudziński 0c53783497 🚀 bump to 2.6.1 2025-09-25 19:14:17 +02:00
Oskar Dudziński 89a03c829a 🐛 Fixed concurrent playlist download (#159) 2025-09-25 19:12:16 +02:00
Oskar Dudziński 3e2c9373fb 🚀 bump to 2.6.0 2025-09-25 18:52:05 +02:00
Oskar Dudziński 3b12f92bd2 Added config and flag for saving m3u file (#158)
*  Added `save_playlist_m3u` flag

*  Changed scan path flag to `--scan-path`

* ♻️ Refactored, edited logs
2025-09-25 18:51:15 +02:00
Oskar Dudziński bc66861f94 ♻️ Refactor download CLI 2025-09-23 19:49:45 +02:00
Oskar Dudziński 1e1b384f39 🚀 bump to 2.6.0a1 2025-09-22 17:43:45 +02:00
Oskar Dudziński ee6bba1d30 Save playlist to M3U file (#157)
* depracated `trackExists`

* add function `savePlaylistM3U`

* add saving playlist m3u
2025-09-22 17:37:26 +02:00
Oskar Dudziński bea4bf32d0 🎨 Format code 2025-09-22 16:36:21 +02:00
Oskar Dudziński e407d7de41 close #155 (#156) 2025-09-22 16:32:42 +02:00
Oskar Dudziński bf6874d9e7 🚀 bump to 2.5.2 2025-09-07 19:56:54 +02:00
xiliourt 4204a4f6ad Added scan_path setting (#151)
* scan_path optional flag

* scan_path

* Update config.py

* Update __init__.py

---------

Co-authored-by: Tepyolas <Tepyolas>
2025-09-07 19:47:47 +02:00
Oskar Dudziński b899d0b286 🚀 bump to 2.5.1 2025-08-19 20:18:44 +02:00
Oskar Dudziński 016440e183 Added album_id to format string
close #146
2025-08-17 19:50:29 +02:00
xiliourt ea3571ae42 🐬 Added actual ghcr.io URL for docker commands (#139)
* Added actual ghcr.io URL for docker commands

Added ghcr.io/oskvr37/tiddl:latest in docker-compose.yml example code and docker run example code, in README.md

* Update Dockerfile
2025-07-26 01:12:13 +02:00
xiliourt f478e9f1d2 Changed FFmpeg to asynchronous (#137)
* Change cli/download to use asyncio.run() for the convert call

Ensures it awaits the return of 'path' before proceeding

* Updated to async convertFileExtension via ffmpeg_asyncio

* Changed to ffmpeg-asyncio dependency

Also requires ffmpeg installed at an OS level

* (Missed a comma)

* Update pyproject.toml
2025-07-19 23:45:29 +02:00
xiliourt 9a8c9d8d2d 🐬 Added Docker stuff (#138)
* Docker flow

* (Commit so my commit is verified)

---------

Co-authored-by: Xiliourt <admin@xiliourt.ovh>
2025-07-18 21:32:51 +02:00
xiliourt e91bf6e655 🐛 Fixed video download flag (#136)
* DOWNLOAD_VIDEO=false > DO_NOT_SKIP=true

DO_NOT_SKIP is intended logic for duplicate files; not intended to override a specific tag requesting not to download videos (my bad!)

This should fix that logic

* Changed --video to capital -V flag

-v is verbose, I was wondering why verbose wasn't working lol
2025-07-18 16:39:22 +02:00
Oskar Dudziński 34c1b1fd4e 🚀 bump to 2.5.0 2025-07-17 11:23:36 +02:00
xiliourt d85fb96a19 Added video download flag and config (#134) 2025-07-17 11:21:41 +02:00
Oskar Dudziński a4a7e66b84 🚀 bump to 2.4.0 2025-06-03 16:41:10 +02:00
Oskar Dudziński 7258df8ec8 Added embedding lyrics to tracks (#129)
* add lyrics api endpoint

* embed lyrics in metadata

* add embed lyrics option
2025-06-03 16:40:14 +02:00
Oskar Dudziński ed0918e7b0 Save album covers on download (#128)
* save cover

* create cover directory before saving

* prepare cover settings

* add cover settings

* add filename setting
2025-06-03 14:50:13 +02:00
Oskar Dudziński a147c94110 🐛 releaseDate can be optional (#127) 2025-05-30 13:07:45 +02:00
Oskar Dudziński 2eb25b81f9 🚀 bump to 2.3.5 2025-05-30 13:04:49 +02:00
16 changed files with 444 additions and 106 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Push Docker Image to ghcr.io
# Run when release is published
on:
release:
types: [published]
workflow_dispatch: # Allow for manual push so I can test it
jobs:
build:
runs-on: ubuntu-latest
# Minimum required permissions
permissions:
contents: read
packages: write
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v3
# Login to ghcr (automatically uses workflow actor and secret)
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pushes to both :latest and :<versionTag>
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/tiddl:${{ github.event.release.tag_name }}
ghcr.io/${{ github.repository_owner }}/tiddl:latest
+20
View File
@@ -0,0 +1,20 @@
# --- Optimised Layer Caching --- #
# Layer 1 (ffmpeg) will never regenerate
# Layer 2 (pip install) will regenerate if pyproject.toml is changed
# Layer 3 (build & install tiddl), rengerates on any code change
FROM python:alpine
WORKDIR /root
# -- Layer 1 - ffmpeg install (it'll stay cached as a layer always) --
RUN apk add --no-cache ffmpeg
# -- Layer 2 - pip install depenencies (remains cached unless pyproject.toml changes) --
# Exports 'depenencies' from pyproject.toml formatted to requirements.txt format, pipelined to pip install
COPY pyproject.toml .
RUN python -c "import tomllib; f=open('pyproject.toml','rb'); print('\n'.join(tomllib.load(f)['project']['dependencies']))" | xargs pip install
# -- Layer 3 - Uncached layer (regenerates anytime a new build is released) --
COPY . .
RUN pip install --no-deps .
RUN rm -rf *
+25
View File
@@ -45,6 +45,31 @@ Commands:
search Search on Tidal.
url Get Tidal URL.
```
## Dockerised Version (no Python required)
Based on python:alpine, slim build
**Docker run example (quickest / easiest)**
```
docker run -rm -v /downloads/dir:/root/Music/Tiddl/ -v ./config/tiddl/:/root/ ghcr.io/oskvr37/tiddl:latest
```
**docker-compose.yml example (not required, though allows for advanced configs)**
```
services:
tiddl:
container_name: tiddl
image: ghcr.io/oskvr37/tiddl:latest
volumes:
- /downloads/dir:/root/Music/Tiddl/ #default dir
- ./config/tiddl/:/root/ # Default location of config file
command: tail -f /dev/null # Keep it running in background
```
**Access the container:**
```
docker exec -it tiddl sh
```
_all other instructions match python version_
# Basic usage
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.3.4"
version = "2.6.2"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.11"
@@ -20,7 +20,7 @@ dependencies = [
"requests-cache>=1.2.1",
"click>=8.1.7",
"mutagen>=1.47.0",
"python-ffmpeg>=2.0.0",
"ffmpeg-asyncio>=0.1.3",
"m3u8>=6.0.0",
"rich>=13.9.4"
]
+5
View File
@@ -83,6 +83,11 @@ class TestApi(unittest.TestCase):
def test_video_stream(self):
self.api.getVideoStream(373513584)
def test_lyrics(self):
track_id = 103805726
lyrics = self.api.getLyrics(track_id)
self.assertEqual(lyrics.trackId, track_id)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -17,6 +17,12 @@ class TestTidalResource(unittest.TestCase):
("playlist/12345678", "playlist", "12345678"),
("https://tidal.com/browse/artist/12345678", "artist", "12345678"),
("artist/12345678", "artist", "12345678"),
(
"https://tidal.com/browse/mix/f93b015796bf93b015796b",
"mix",
"f93b015796bf93b015796b",
),
("mix/f93b015796bf93b015796b", "mix", "f93b015796bf93b015796b"),
]
for resource, expected_type, expected_id in positive_cases:
+26 -7
View File
@@ -26,6 +26,8 @@ from tiddl.models.api import (
TrackStream,
Video,
VideoStream,
Lyrics,
MixItems,
)
from tiddl.models.constants import TrackQuality
@@ -52,6 +54,7 @@ class Limits:
ALBUM_ITEMS = 10
ALBUM_ITEMS_MAX = 100
PLAYLIST = 50
MIX_ITEMS = 100
class TidalApi:
@@ -123,9 +126,7 @@ class TidalApi:
Album, f"albums/{album_id}", {"countryCode": self.country_code}
)
def getAlbumItems(
self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0
):
def getAlbumItems(self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0):
return self.fetch(
AlbumItems,
f"albums/{album_id}/items",
@@ -176,6 +177,23 @@ class TidalApi:
expire_after=3600,
)
def getMix(
self,
mix_id: str | int,
limit=LIMITS.MIX_ITEMS,
offset=0,
):
return self.fetch(
MixItems,
f"mixes/{mix_id}/items",
{
"countryCode": self.country_code,
"limit": limit,
"offset": offset,
},
expire_after=3600,
)
def getFavorites(self):
return self.fetch(
Favorites,
@@ -191,9 +209,7 @@ class TidalApi:
{"countryCode": self.country_code},
)
def getPlaylistItems(
self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0
):
def getPlaylistItems(self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0):
return self.fetch(
PlaylistItems,
f"playlists/{playlist_uuid}/items",
@@ -214,8 +230,11 @@ class TidalApi:
)
def getSession(self):
return self.fetch(SessionResponse, "sessions", expire_after=DO_NOT_CACHE)
def getLyrics(self, track_id: str | int):
return self.fetch(
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
Lyrics, f"tracks/{track_id}/lyrics", {"countryCode": self.country_code}
)
def getTrack(self, track_id: str | int):
+2 -6
View File
@@ -34,18 +34,14 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
)
)
LEVEL = (
logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO
)
LEVEL = logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO
rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True)
rich_handler.setLevel(LEVEL)
if LEVEL == logging.DEBUG:
rich_handler.setFormatter(
logging.Formatter(
"[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]"
)
logging.Formatter("[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]")
)
logging.basicConfig(
+169 -35
View File
@@ -1,8 +1,9 @@
import logging
import click
import asyncio
from time import perf_counter
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, Future
from pathlib import Path
from requests import Session
@@ -23,7 +24,8 @@ from tiddl.utils import (
TidalResource,
formatResource,
convertFileExtension,
trackExists,
savePlaylistM3U,
findTrackFilename,
)
from tiddl.cli.ctx import Context, passContext
@@ -80,22 +82,67 @@ from typing import List, Union
type=click.Choice(SinglesFilter.__args__),
help="Defines how to treat artist EPs and singles, used while downloading artist.",
)
@click.option(
"--lyrics",
"-l",
"EMBED_LYRICS",
is_flag=True,
help="Embed track lyrics in file metadata.",
)
@click.option(
"--video",
"-V",
"DOWNLOAD_VIDEO",
is_flag=True,
help="Enable downloading videos",
)
@click.option(
"--scan-path",
"SCAN_PATH",
type=str,
help="Base directory to scan for existing tracks. Default is 'path'",
)
@click.option(
"--save-m3u",
"-m3u",
"SAVE_M3U",
is_flag=True,
help="Save M3U file for playlists.",
)
@passContext
def DownloadCommand(
ctx: Context,
QUALITY: TrackArg | None,
TEMPLATE: str | None,
PATH: str | None,
THREADS_COUNT: int,
THREADS_COUNT: int | None,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
EMBED_LYRICS: bool,
DOWNLOAD_VIDEO: bool,
SCAN_PATH: str | None,
SAVE_M3U: bool,
):
"""Download resources"""
DOWNLOAD_VIDEO = DOWNLOAD_VIDEO or ctx.obj.config.download.download_video
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
EMBED_LYRICS = EMBED_LYRICS or ctx.obj.config.download.embed_lyrics
# TODO: pretty print
logging.debug((QUALITY, TEMPLATE, PATH, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER))
logging.debug(
(
QUALITY,
TEMPLATE,
PATH,
THREADS_COUNT,
DO_NOT_SKIP,
SINGLES_FILTER,
EMBED_LYRICS,
DOWNLOAD_VIDEO,
SCAN_PATH,
SAVE_M3U,
)
)
DOWNLOAD_QUALITY = ARG_TO_QUALITY[QUALITY or ctx.obj.config.download.quality]
@@ -119,7 +166,7 @@ def DownloadCommand(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
) -> Path:
if isinstance(item, Track):
track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY)
description = (
@@ -182,29 +229,45 @@ def DownloadCommand(
if isinstance(item, Track):
if track_stream.audioQuality == "HI_RES_LOSSLESS":
path = convertFileExtension(
source_file=path,
extension=".flac",
remove_source=True,
is_video=False,
copy_audio=True, # extract flac from m4a container
path = asyncio.run(
convertFileExtension(
source_file=path,
extension=".flac",
remove_source=True,
is_video=False,
copy_audio=True, # extract flac from m4a container
)
)
if not cover_data and item.album.cover:
cover_data = Cover(item.album.cover).content
if EMBED_LYRICS:
lyrics_subtitles = api.getLyrics(item.id).subtitles
else:
lyrics_subtitles = ""
try:
addMetadata(path, item, cover_data, credits, album_artist=album_artist)
addMetadata(
path,
item,
cover_data,
credits,
album_artist=album_artist,
lyrics=lyrics_subtitles,
)
except Exception as e:
logging.error(f"Can not add metadata to: {path}, {e}")
elif isinstance(item, Video):
path = convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
path = asyncio.run(
convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
)
)
try:
@@ -215,6 +278,8 @@ def DownloadCommand(
progress.remove_task(task_id)
logging.info(f"{item.title!r}{speed:.2f} Mbps • {size:.2f} MB")
return path
pool = ThreadPoolExecutor(
max_workers=THREADS_COUNT or ctx.obj.config.download.threads
)
@@ -225,39 +290,64 @@ def DownloadCommand(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
) -> Future[Path] | None:
if not item.allowStreaming:
logging.warning(
f"{type(item).__name__} '{item.title}' does not allow streaming"
)
return
path = Path(PATH) if PATH else ctx.obj.config.download.path
path /= f"{filename}.*"
download_path = Path(PATH) if PATH else ctx.obj.config.download.path
download_path /= f"{filename}.*"
if not DO_NOT_SKIP: # check if item is already downloaded
scan_path = Path(SCAN_PATH) if SCAN_PATH else ctx.obj.config.download.scan_path
if scan_path:
scan_path /= f"{filename}.*"
else:
scan_path = download_path
# Respect DOWNLOAD_VIDEO = FALSE over DO_NOT_SKIP (as it's for the file exists check)
if isinstance(item, Video) and not DOWNLOAD_VIDEO:
logging.warning(f"Video '{item.title}' skipped as DOWNLOAD_VIDEO is false")
return
# check if item is already downloaded (unless DO_NOT_SKIP is set, then override anything)
if not DO_NOT_SKIP:
if isinstance(item, Track):
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path):
logging.warning(f"Track '{item.title}' skipped")
return
track_path = findTrackFilename(
item.audioQuality, DOWNLOAD_QUALITY, scan_path
)
if track_path.exists():
logging.info(f"Track '{item.title}' skipped - exists")
future = Future()
future.set_result(track_path)
return future
elif isinstance(item, Video):
if path.with_suffix(".mp4").exists():
logging.warning(f"Video '{item.title}' skipped")
if scan_path.with_suffix(".mp4").exists():
logging.info(f"Video '{item.title}' skipped - exists")
return
pool.submit(
future = pool.submit(
handleItemDownload,
item=item,
path=path,
path=download_path,
cover_data=cover_data,
credits=credits,
album_artist=album_artist,
)
return future
def downloadAlbum(album: Album):
logging.info(f"Album {album.title!r}")
cover_data = Cover(album.cover).content if album.cover else b""
cover = (
Cover(uid=album.cover, size=ctx.obj.config.cover.size)
if album.cover
else None
)
is_cover_saved = False
offset = 0
@@ -271,10 +361,16 @@ def DownloadCommand(
album_artist=album.artist.name,
)
if cover and not is_cover_saved and ctx.obj.config.cover.save:
path = Path(PATH) if PATH else ctx.obj.config.download.path
cover_path = path / Path(filename).parent
cover.save(cover_path, ctx.obj.config.cover.filename)
is_cover_saved = True
submitItem(
item.item,
filename,
cover_data,
cover.content if cover else b"",
item.credits,
album.artist.name,
)
@@ -285,7 +381,7 @@ def DownloadCommand(
offset += album_items.limit
def handleResource(resource: TidalResource) -> None:
logging.debug(f"Handling Resource '{resource}'")
logging.debug(f"'{resource}'")
match resource.type:
case "track":
@@ -309,6 +405,16 @@ def DownloadCommand(
downloadAlbum(album)
case "mix":
mix = api.getMix(resource.id)
for mix_item in mix.items:
filename = formatResource(
TEMPLATE or ctx.obj.config.template.track, mix_item.item
)
submitItem(mix_item.item, filename)
case "artist":
artist = api.getArtist(resource.id)
logging.info(f"Artist {artist.name!r}")
@@ -342,8 +448,11 @@ def DownloadCommand(
case "playlist":
playlist = api.getPlaylist(resource.id)
logging.info(f"Playlist {playlist.title!r}")
logging.info(f"downloading playlist {playlist.title!r}")
offset = 0
playlist_path = None
futures: list[tuple[Future[Path], Track]] = []
while True:
playlist_items = api.getPlaylistItems(playlist.uuid, offset=offset)
@@ -356,7 +465,11 @@ def DownloadCommand(
playlist_index=item.item.index // 100000,
)
submitItem(item.item, filename)
future = submitItem(item.item, filename)
if future:
futures.append((future, item.item))
playlist_path = Path(filename).parent
if (
playlist_items.limit + playlist_items.offset
@@ -366,6 +479,27 @@ def DownloadCommand(
offset += playlist_items.limit
playlist_tracks: list[tuple[Path, Track]] = []
for future, track in futures:
track_path = future.result()
playlist_tracks.append((track_path, track))
path = Path(PATH) if PATH else ctx.obj.config.download.path
if playlist_path and (SAVE_M3U or ctx.obj.config.download.save_playlist_m3u):
savePlaylistM3U(
playlist_tracks=playlist_tracks,
path=path / playlist_path,
filename=f"{playlist.title}.m3u",
)
if playlist.squareImage and playlist_path:
cover = Cover(
uid=playlist.squareImage,
size=1080, # playlist cover must be 1080x1080
)
cover.save(path / playlist_path, ctx.obj.config.cover.filename)
progress.start()
# TODO: make sure every resource is unique
+7 -1
View File
@@ -3,7 +3,13 @@ import click
from tiddl.utils import TidalResource, ResourceTypeLiteral
from tiddl.cli.ctx import Context, passContext
ResourceTypeList: list[ResourceTypeLiteral] = ["track", "video", "album", "artist", "playlist"]
ResourceTypeList: list[ResourceTypeLiteral] = [
"track",
"video",
"album",
"artist",
"playlist",
]
@click.group("fav")
+12
View File
@@ -15,6 +15,7 @@ makedirs(HOME_PATH, exist_ok=True)
CONFIG_PATH = HOME_PATH / "tiddl.json"
CONFIG_INDENT = 2
class TemplateConfig(BaseModel):
track: str = "{artist} - {title}"
video: str = "{artist} - {title}"
@@ -27,6 +28,10 @@ class DownloadConfig(BaseModel):
path: Path = Path.home() / "Music" / "Tiddl"
threads: int = 4
singles_filter: SinglesFilter = "none"
embed_lyrics: bool = False
download_video: bool = False
scan_path: Path | None = path
save_playlist_m3u: bool = False
class AuthConfig(BaseModel):
@@ -37,9 +42,16 @@ class AuthConfig(BaseModel):
country_code: str = ""
class CoverConfig(BaseModel):
save: bool = False
size: int = 1280
filename: str = "cover.jpg"
class Config(BaseModel):
template: TemplateConfig = TemplateConfig()
download: DownloadConfig = DownloadConfig()
cover: CoverConfig = CoverConfig()
auth: AuthConfig = AuthConfig()
omit_cache: bool = False
+1 -3
View File
@@ -80,9 +80,7 @@ def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]:
elif codecs.startswith("mp4"):
file_extension = ".m4a"
else:
raise ValueError(
f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}"
)
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")
return urls, file_extension
+34 -25
View File
@@ -1,6 +1,7 @@
import logging
import requests
from os import makedirs
from pathlib import Path
from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4
@@ -23,6 +24,7 @@ def addMetadata(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
lyrics="",
):
logger.debug((track_path, track.id))
@@ -38,8 +40,12 @@ def addMetadata(
picture.mime = "image/jpeg"
metadata.add_picture(picture)
metadata["TITLE"] = track.title + (" ({})".format(track.version) if track.version else "")
metadata["WORK"] = track.title + (" ({})".format(track.version) if track.version else "")
metadata["TITLE"] = track.title + (
" ({})".format(track.version) if track.version else ""
)
metadata["WORK"] = track.title + (
" ({})".format(track.version) if track.version else ""
)
metadata["TRACKNUMBER"] = str(track.trackNumber)
metadata["DISCNUMBER"] = str(track.volumeNumber)
@@ -56,9 +62,7 @@ def addMetadata(
if track.streamStartDate:
metadata["DATE"] = track.streamStartDate.strftime("%Y-%m-%d")
metadata["ORIGINALDATE"] = track.streamStartDate.strftime(
"%Y-%m-%d"
)
metadata["ORIGINALDATE"] = track.streamStartDate.strftime("%Y-%m-%d")
metadata["YEAR"] = str(track.streamStartDate.strftime("%Y"))
metadata["ORIGINALYEAR"] = str(track.streamStartDate.strftime("%Y"))
@@ -75,13 +79,22 @@ def addMetadata(
contributor.name for contributor in entry.contributors
]
if lyrics:
metadata["LYRICS"] = lyrics
elif extension == ".m4a":
if cover_data:
if lyrics or cover_data:
metadata = MutagenMP4(track_path)
metadata["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
metadata.save(track_path)
if lyrics:
metadata["\xa9lyr"] = [lyrics]
if cover_data:
metadata["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
metadata.save()
metadata = MutagenEasyMP4(track_path)
metadata.update(
@@ -91,13 +104,9 @@ def addMetadata(
"discnumber": str(track.volumeNumber),
"copyright": track.copyright if track.copyright else "",
"albumartist": track.artist.name if track.artist else "",
"artist": ";".join(
[artist.name.strip() for artist in track.artists]
),
"artist": ";".join([artist.name.strip() for artist in track.artists]),
"album": track.album.title,
"date": str(track.streamStartDate)
if track.streamStartDate
else "",
"date": str(track.streamStartDate) if track.streamStartDate else "",
"bpm": str(track.bpm or 0),
}
)
@@ -118,9 +127,7 @@ def addVideoMetadata(path: Path, video: Video):
{
"title": video.title,
"albumartist": video.artist.name if video.artist else "",
"artist": ";".join(
[artist.name.strip() for artist in video.artists]
),
"artist": ";".join([artist.name.strip() for artist in video.artists]),
"album": video.album.title if video.album else "",
"date": str(video.streamStartDate) if video.streamStartDate else "",
}
@@ -151,7 +158,9 @@ class Cover:
self.uid = uid
formatted_uid = uid.replace("-", "/")
self.url = f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg"
self.url = (
f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg"
)
logger.debug((self.uid, self.url))
@@ -161,26 +170,26 @@ class Cover:
req = requests.get(self.url)
if req.status_code != 200:
logger.error(
f"could not download cover. ({req.status_code}) {self.url}"
)
logger.error(f"could not download cover. ({req.status_code}) {self.url}")
return b""
logger.debug(f"got cover: {self.uid}")
return req.content
def save(self, directory_path: Path):
def save(self, directory_path: Path, filename="cover.jpg"):
if not self.content:
logger.error("cover file content is empty")
return
file = directory_path / "cover.jpg"
file = directory_path / filename
if file.exists():
logger.debug(f"cover already exists ({file})")
return
makedirs(directory_path, exist_ok=True)
try:
with file.open("wb") as f:
f.write(self.content)
+18
View File
@@ -11,6 +11,7 @@ __all__ = [
"Favorites",
"TrackStream",
"Search",
"Lyrics",
]
@@ -101,6 +102,13 @@ class PlaylistItems(Items):
items: List[Union[PlaylistTrackItem, PlaylistVideoItem]]
class MixItems(Items):
class MixItem(BaseModel):
item: Track
type: ItemType = "track"
items: List[MixItem]
class Favorites(BaseModel):
PLAYLIST: List[str]
ALBUM: List[str]
@@ -167,3 +175,13 @@ class Search(BaseModel):
tracks: Tracks
videos: Videos
topHit: Optional[TopHit] = None
class Lyrics(BaseModel):
isRightToLeft: bool
lyrics: str
lyricsProvider: str
providerCommontrackId: str
providerLyricsId: str
subtitles: str
trackId: int
+1 -1
View File
@@ -120,7 +120,7 @@ class Album(BaseModel):
numberOfTracks: int
numberOfVideos: int
numberOfVolumes: int
releaseDate: str
releaseDate: Optional[str] = None
copyright: Optional[str] = None
type: str
version: Optional[str] = None
+77 -26
View File
@@ -2,7 +2,8 @@ import re
import os
import logging
from ffmpeg import FFmpeg
from ffmpeg_asyncio import FFmpeg
from ffmpeg_asyncio.types import Option as FFmpegOption
from pydantic import BaseModel
from urllib.parse import urlparse
@@ -13,7 +14,7 @@ from typing import Literal, Union, get_args
from tiddl.models.constants import TrackQuality, QUALITY_TO_ARG
from tiddl.models.resource import Track, Video
ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist"]
ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist", "mix"]
class TidalResource(BaseModel):
@@ -40,7 +41,14 @@ class TidalResource(BaseModel):
if resource_type not in get_args(ResourceTypeLiteral):
raise ValueError(f"Invalid resource type: {resource_type}")
if not resource_id.isdigit() and resource_type != "playlist":
digit_resource_types: list[ResourceTypeLiteral] = [
"track",
"album",
"video",
"artist",
]
if resource_type in digit_resource_types and not resource_id.isdigit():
raise ValueError(f"Invalid resource id: {resource_id}")
return cls(type=resource_type, id=resource_id) # type: ignore
@@ -80,9 +88,7 @@ def formatTrack(
"disc": track.volumeNumber,
"date": (track.streamStartDate if track.streamStartDate else ""),
# i think we can remove year as we are able to format date
"year": track.streamStartDate.strftime("%Y")
if track.streamStartDate
else "",
"year": track.streamStartDate.strftime("%Y") if track.streamStartDate else "",
"playlist": sanitizeString(playlist_title),
"bpm": track.bpm or "",
"quality": QUALITY_TO_ARG[track.audioQuality],
@@ -125,13 +131,14 @@ def formatResource(
"artists": ", ".join(features + [artist]),
"features": ", ".join(features),
"album": sanitizeString(resource.album.title if resource.album else ""),
"album_id": str(resource.album.id if resource.album else ""),
"number": resource.trackNumber,
"disc": resource.volumeNumber,
"date": (resource.streamStartDate if resource.streamStartDate else ""),
# i think we can remove year as we are able to format date
"year": resource.streamStartDate.strftime("%Y")
if resource.streamStartDate
else "",
"year": (
resource.streamStartDate.strftime("%Y") if resource.streamStartDate else ""
),
"playlist": sanitizeString(playlist_title),
"album_artist": sanitizeString(album_artist),
"playlist_number": playlist_index or 0,
@@ -166,11 +173,11 @@ def formatResource(
return formatted_template
def trackExists(
def findTrackFilename(
track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path
):
) -> Path:
"""
Predict track extension and check if track file exists.
Predict track extension.
"""
FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"]
@@ -182,10 +189,10 @@ def trackExists(
full_file_name = file_name.with_suffix(extension)
return full_file_name.exists()
return full_file_name
def convertFileExtension(
async def convertFileExtension(
source_file: Path,
extension: str,
remove_source=False,
@@ -204,27 +211,71 @@ def convertFileExtension(
logging.error(e)
return source_file
logging.debug((source_file, output_file, extension))
logging.debug((source_file, output_file, extension, copy_audio, is_video))
if extension == source_file.suffix:
logging.debug("Conversion not required, already %s", extension)
return source_file
ffmpeg_args = {"loglevel": "error"}
ffmpeg_args: dict[str, FFmpegOption | None] = {"loglevel": "error"}
if copy_audio:
ffmpeg_args["c:a"] = "copy"
ffmpeg_args["acodec"] = "copy"
if is_video:
ffmpeg_args["c:v"] = "copy"
ffmpeg_args["vcodec"] = "copy"
(
FFmpeg()
.option("y")
.input(url=str(source_file))
.output(url=str(output_file), options=None, **ffmpeg_args)
).execute()
try:
logging.debug("Trying conversion")
ffmpeg = FFmpeg().option("y")
ffmpeg.input(str(source_file))
ffmpeg.output(str(output_file), ffmpeg_args)
if remove_source:
os.remove(source_file)
@ffmpeg.on("completed")
def on_completed():
logging.debug(f"converted {output_file}")
if remove_source:
try:
os.remove(source_file)
except OSError as e:
logging.error(f"can't remove source file {source_file}: {e}")
await ffmpeg.execute()
except Exception as e:
logging.error(f"can't convert file {source_file}: {e}")
return source_file
return output_file
def savePlaylistM3U(
playlist_tracks: list[tuple[Path, Track]], path: Path, filename="playlist.m3u"
):
"""
playlist_tracks: [track_path, Track]
path: m3u file location
filename: name of the m3u file
"""
file = path / sanitizeString(filename)
logging.debug(f"saving m3u file at {file}")
if not playlist_tracks:
logging.warning(f"playlist {file} is empty")
return
try:
with file.open("w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for track_path, track in playlist_tracks:
f.write(
f"#EXTINF:{track.duration},{track.artist.name if track.artist else ''} - {track.title}\n{track_path}\n"
)
logging.debug(
f"saved m3u file as {file} with {len(playlist_tracks)} tracks"
)
except Exception as e:
logging.error(f"can't save playlist m3u file: {e}")