mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f44f9780a | |||
| 5d420eeec5 | |||
| 3053e91134 | |||
| 36daea61e0 | |||
| 12a2d4cf5f | |||
| 0c53783497 | |||
| 89a03c829a | |||
| 3e2c9373fb | |||
| 3b12f92bd2 | |||
| bc66861f94 | |||
| 1e1b384f39 | |||
| ee6bba1d30 | |||
| bea4bf32d0 | |||
| e407d7de41 | |||
| bf6874d9e7 | |||
| 4204a4f6ad | |||
| b899d0b286 | |||
| 016440e183 | |||
| ea3571ae42 | |||
| f478e9f1d2 | |||
| 9a8c9d8d2d | |||
| e91bf6e655 | |||
| 34c1b1fd4e | |||
| d85fb96a19 | |||
| a4a7e66b84 | |||
| 7258df8ec8 | |||
| ed0918e7b0 | |||
| a147c94110 | |||
| 2eb25b81f9 | |||
| 1f1e89a97a | |||
| f32bab434c |
@@ -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
@@ -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 *
|
||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tiddl"
|
||||
version = "2.3.3"
|
||||
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"
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ class AuthUser(BaseModel):
|
||||
postalcode: Optional[str]
|
||||
usState: Optional[str]
|
||||
phoneNumber: Optional[str]
|
||||
birthday: Optional[str]
|
||||
birthday: Optional[int]
|
||||
channelId: int
|
||||
parentId: int
|
||||
acceptedEULA: bool
|
||||
|
||||
@@ -67,7 +67,7 @@ class Video(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
cover: str
|
||||
vibrantColor: str
|
||||
vibrantColor: Optional[str] = None
|
||||
videoCover: Optional[str] = None
|
||||
|
||||
id: int
|
||||
@@ -77,7 +77,7 @@ class Video(BaseModel):
|
||||
streamStartDate: Optional[datetime] = None
|
||||
imagePath: Optional[str] = None
|
||||
imageId: str
|
||||
vibrantColor: str
|
||||
vibrantColor: Optional[str] = None
|
||||
duration: int
|
||||
quality: str
|
||||
streamReady: bool
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user