mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-23 11:22:16 -05:00
282 lines
8.3 KiB
Python
282 lines
8.3 KiB
Python
import re
|
|
import os
|
|
import logging
|
|
|
|
from ffmpeg_asyncio import FFmpeg
|
|
from ffmpeg_asyncio.types import Option as FFmpegOption
|
|
|
|
from pydantic import BaseModel
|
|
from urllib.parse import urlparse
|
|
from pathlib import Path
|
|
|
|
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", "mix"]
|
|
|
|
|
|
class TidalResource(BaseModel):
|
|
type: ResourceTypeLiteral
|
|
id: str
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
return f"https://listen.tidal.com/{self.type}/{self.id}"
|
|
|
|
@classmethod
|
|
def fromString(cls, string: str):
|
|
"""
|
|
Extracts the resource type (e.g., "track", "album")
|
|
and resource ID from a given input string.
|
|
|
|
The input string can either be a full URL or a shorthand string
|
|
in the format `resource_type/resource_id` (e.g., `track/12345678`).
|
|
"""
|
|
|
|
path = urlparse(string).path
|
|
resource_type, resource_id = path.split("/")[-2:]
|
|
|
|
if resource_type not in get_args(ResourceTypeLiteral):
|
|
raise ValueError(f"Invalid resource type: {resource_type}")
|
|
|
|
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
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.type}/{self.id}"
|
|
|
|
|
|
def sanitizeString(string: str) -> str:
|
|
pattern = r'[\\/:"*?<>|]+'
|
|
return re.sub(pattern, "", string)
|
|
|
|
|
|
def formatTrack(
|
|
template: str,
|
|
track: Track,
|
|
album_artist="",
|
|
playlist_title="",
|
|
playlist_index=0,
|
|
) -> str:
|
|
artist = sanitizeString(track.artist.name) if track.artist else ""
|
|
features = [
|
|
sanitizeString(track_artist.name)
|
|
for track_artist in track.artists
|
|
if track_artist.name != artist
|
|
]
|
|
|
|
track_dict = {
|
|
"id": str(track.id),
|
|
"title": sanitizeString(track.title),
|
|
"version": sanitizeString(track.version or ""),
|
|
"artist": artist,
|
|
"artists": ", ".join(features + [artist]),
|
|
"features": ", ".join(features),
|
|
"album": sanitizeString(track.album.title),
|
|
"number": track.trackNumber,
|
|
"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 "",
|
|
"playlist": sanitizeString(playlist_title),
|
|
"bpm": track.bpm or "",
|
|
"quality": QUALITY_TO_ARG[track.audioQuality],
|
|
"album_artist": sanitizeString(album_artist),
|
|
"playlist_number": playlist_index or 0,
|
|
}
|
|
|
|
formatted_track = template.format(**track_dict).strip()
|
|
|
|
disallowed_chars = r'[\\:"*?<>|]+'
|
|
invalid_chars = re.findall(disallowed_chars, formatted_track)
|
|
|
|
if invalid_chars:
|
|
raise ValueError(
|
|
f"Template '{template}' and formatted track '{formatted_track}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}"
|
|
)
|
|
|
|
return formatted_track
|
|
|
|
|
|
def formatResource(
|
|
template: str,
|
|
resource: Union[Track, Video],
|
|
album_artist="",
|
|
playlist_title="",
|
|
playlist_index=0,
|
|
) -> str:
|
|
artist = sanitizeString(resource.artist.name) if resource.artist else ""
|
|
|
|
features = [
|
|
sanitizeString(item_artist.name)
|
|
for item_artist in resource.artists
|
|
if item_artist.name != artist
|
|
]
|
|
|
|
resource_dict = {
|
|
"id": str(resource.id),
|
|
"title": sanitizeString(resource.title),
|
|
"artist": artist,
|
|
"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 ""
|
|
),
|
|
"playlist": sanitizeString(playlist_title),
|
|
"album_artist": sanitizeString(album_artist),
|
|
"playlist_number": playlist_index or 0,
|
|
"quality": "",
|
|
"version": "",
|
|
"bpm": "",
|
|
}
|
|
|
|
if isinstance(resource, Track):
|
|
resource_dict.update(
|
|
{
|
|
"version": sanitizeString(resource.version or ""),
|
|
"quality": QUALITY_TO_ARG[resource.audioQuality],
|
|
"bpm": resource.bpm or "",
|
|
}
|
|
)
|
|
|
|
elif isinstance(resource, Video):
|
|
resource_dict.update({"quality": resource.quality})
|
|
|
|
formatted_template = template.format(**resource_dict).strip()
|
|
|
|
disallowed_chars = r'[\\:"*?<>|]+'
|
|
invalid_chars = re.findall(disallowed_chars, formatted_template)
|
|
|
|
if invalid_chars:
|
|
raise ValueError(
|
|
f"Template '{template}' and formatted resource '{formatted_template}'"
|
|
f"contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}"
|
|
)
|
|
|
|
return formatted_template
|
|
|
|
|
|
def findTrackFilename(
|
|
track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path
|
|
) -> Path:
|
|
"""
|
|
Predict track extension.
|
|
"""
|
|
|
|
FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"]
|
|
|
|
if download_quality in FLAC_QUALITIES and track_quality in FLAC_QUALITIES:
|
|
extension = ".flac"
|
|
else:
|
|
extension = ".m4a"
|
|
|
|
full_file_name = file_name.with_suffix(extension)
|
|
|
|
return full_file_name
|
|
|
|
|
|
async def convertFileExtension(
|
|
source_file: Path,
|
|
extension: str,
|
|
remove_source=False,
|
|
is_video=False,
|
|
copy_audio=False,
|
|
) -> Path:
|
|
"""
|
|
Converts `source_file` extension and returns `Path` of file with new `extension`.
|
|
|
|
Removes `source_file` when `remove_source` is truthy.
|
|
"""
|
|
|
|
try:
|
|
output_file = source_file.with_suffix(extension)
|
|
except ValueError as e:
|
|
logging.error(e)
|
|
return source_file
|
|
|
|
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: dict[str, FFmpegOption | None] = {"loglevel": "error"}
|
|
|
|
if copy_audio:
|
|
ffmpeg_args["acodec"] = "copy"
|
|
|
|
if is_video:
|
|
ffmpeg_args["vcodec"] = "copy"
|
|
|
|
try:
|
|
logging.debug("Trying conversion")
|
|
ffmpeg = FFmpeg().option("y")
|
|
ffmpeg.input(str(source_file))
|
|
ffmpeg.output(str(output_file), ffmpeg_args)
|
|
|
|
@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}")
|