Compare commits

...

17 Commits

Author SHA1 Message Date
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
Oskar Dudziński 1f1e89a97a 🚀 bump to 2.3.4 2025-05-23 10:41:03 +02:00
Oskar Dudziński f32bab434c 🐛 Fixed incorrect model fields 2025-05-23 10:39:49 +02:00
13 changed files with 238 additions and 44 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.3"
version = "2.5.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
@@ -26,6 +26,7 @@ from tiddl.models.api import (
TrackStream,
Video,
VideoStream,
Lyrics
)
from tiddl.models.constants import TrackQuality
@@ -218,6 +219,11 @@ class TidalApi:
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
)
def getLyrics(self, track_id: str | int):
return self.fetch(
Lyrics, f"tracks/{track_id}/lyrics", {"countryCode": self.country_code}
)
def getTrack(self, track_id: str | int):
return self.fetch(
Track, f"tracks/{track_id}", {"countryCode": self.country_code}
+71 -15
View File
@@ -1,6 +1,6 @@
import logging
import click
import asyncio
from time import perf_counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -80,6 +80,26 @@ 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 music directory to scan for existing. Default is 'path'",
)
@passContext
def DownloadCommand(
ctx: Context,
@@ -89,13 +109,27 @@ def DownloadCommand(
THREADS_COUNT: int,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
EMBED_LYRICS: bool,
DOWNLOAD_VIDEO: bool,
SCAN_PATH: str | None
):
"""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_QUALITY = ARG_TO_QUALITY[QUALITY or ctx.obj.config.download.quality]
@@ -182,30 +216,35 @@ def DownloadCommand(
if isinstance(item, Track):
if track_stream.audioQuality == "HI_RES_LOSSLESS":
path = convertFileExtension(
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(
path = asyncio.run(convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
)
))
try:
addVideoMetadata(path, item)
@@ -234,15 +273,21 @@ def DownloadCommand(
path = Path(PATH) if PATH else ctx.obj.config.download.path
path /= f"{filename}.*"
scan_path = Path(SCAN_PATH or ctx.obj.config.download.scan_path) / f"{filename}.*" if (SCAN_PATH or ctx.obj.config.download.scan_path) else path # Scan scan_path if set, else scans 'path'.
if not DO_NOT_SKIP: # check if item is already downloaded
# 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
if not DO_NOT_SKIP: # check if item is already downloaded (unless DO_NOT_SKIP is set, then override anything)
if isinstance(item, Track):
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path):
logging.warning(f"Track '{item.title}' skipped")
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, scan_path):
logging.warning(f"Track '{item.title}' skipped - exists")
return
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.warning(f"Video '{item.title}' skipped - exists")
return
pool.submit(
@@ -257,7 +302,12 @@ def DownloadCommand(
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 +321,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,
)
+10
View File
@@ -27,6 +27,9 @@ 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 = None
class AuthConfig(BaseModel):
@@ -37,9 +40,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
+21 -8
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))
@@ -75,13 +77,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(
@@ -170,16 +181,18 @@ class Cover:
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:
+11
View File
@@ -11,6 +11,7 @@ __all__ = [
"Favorites",
"TrackStream",
"Search",
"Lyrics"
]
@@ -167,3 +168,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
@@ -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
+3 -3
View File
@@ -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
+24 -15
View File
@@ -2,7 +2,7 @@ import re
import os
import logging
from ffmpeg import FFmpeg
from ffmpeg_asyncio import FFmpeg
from pydantic import BaseModel
from urllib.parse import urlparse
@@ -125,6 +125,7 @@ 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 ""),
@@ -185,7 +186,7 @@ def trackExists(
return full_file_name.exists()
def convertFileExtension(
async def convertFileExtension(
source_file: Path,
extension: str,
remove_source=False,
@@ -204,27 +205,35 @@ 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"}
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("Conversion successful for: %s", output_file)
if remove_source:
try:
os.remove(source_file)
except OSError as e:
logging.error(f"Error removing source file {source_file}: {e}")
await ffmpeg.execute()
except Exception as e:
logging.error(f"FFMPEG Error during conversion of {source_file}: {e}")
return source_file
return output_file