Option added to embed and download album art.

This commit is contained in:
Robert Honz
2024-11-11 10:05:32 +01:00
parent 6732c5e15e
commit d7112063a0
6 changed files with 93 additions and 53 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ profile = "black"
multi_line_output = 3 multi_line_output = 3
indent = 4 indent = 4
color_output = true color_output = true
known_third_party = ["Crypto", "PySide6", "coloredlogs", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"] known_third_party = ["Crypto", "PySide6", "coloredlogs", "constants", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"]
[tool.mypy] [tool.mypy]
# https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file
+1
View File
@@ -5,6 +5,7 @@ REQUESTS_TIMEOUT_SEC: int = 45
EXTENSION_LYRICS: str = ".lrc" EXTENSION_LYRICS: str = ".lrc"
UNIQUIFY_THRESHOLD: int = 99 UNIQUIFY_THRESHOLD: int = 99
FILENAME_SANITIZE_PLACEHOLDER: str = "_" FILENAME_SANITIZE_PLACEHOLDER: str = "_"
COVER_NAME: str = "cover.jpg"
class QualityVideo(StrEnum): class QualityVideo(StrEnum):
+79 -15
View File
@@ -10,6 +10,7 @@ from uuid import uuid4
import ffmpeg import ffmpeg
import m3u8 import m3u8
import requests import requests
from constants import COVER_NAME
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from rich.progress import Progress, TaskID from rich.progress import Progress, TaskID
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
@@ -189,6 +190,7 @@ class Download:
download_delay: bool = False, download_delay: bool = False,
quality_audio: Quality | None = None, quality_audio: Quality | None = None,
quality_video: QualityVideo | None = None, quality_video: QualityVideo | None = None,
is_parent_album: bool = False,
) -> (bool, str): ) -> (bool, str):
try: try:
if media_id and media_type: if media_id and media_type:
@@ -278,10 +280,13 @@ class Download:
tmp_path_file = self._extract_flac(tmp_path_file) tmp_path_file = self._extract_flac(tmp_path_file)
tmp_path_lyrics: pathlib.Path | None = None tmp_path_lyrics: pathlib.Path | None = None
tmp_path_cover: pathlib.Path | None = None
# Write metadata to file. # Write metadata to file.
if not isinstance(media, Video): if not isinstance(media, Video):
result_metadata, tmp_path_lyrics = self.metadata_write(media, tmp_path_file) result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write(
media, tmp_path_file, is_parent_album
)
# Move final file to the configured destination directory. # Move final file to the configured destination directory.
os.makedirs(os.path.dirname(path_media_dst), exist_ok=True) os.makedirs(os.path.dirname(path_media_dst), exist_ok=True)
@@ -289,7 +294,11 @@ class Download:
# Move lyrics file # Move lyrics file
if self.settings.data.lyrics_file and not isinstance(media, Video): if self.settings.data.lyrics_file and not isinstance(media, Video):
self._move_lyrics(path_media_dst, tmp_path_lyrics) self._move_lyrics(tmp_path_lyrics, path_media_dst)
# Move cover file
if self.settings.data.cover_album_file:
self._move_cover(tmp_path_cover, path_media_dst)
else: else:
self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'") self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
@@ -327,13 +336,13 @@ class Download:
return quality_old return quality_old
def _move_lyrics(self, file_media_dst: str, path_lyrics: pathlib.Path) -> bool: def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool:
result: bool result: bool
# Build tmp lyrics filename
# Check if the file was downloaded # Check if the file was downloaded
if path_lyrics and os.path.isfile(path_lyrics): if path_file_source and os.path.isfile(path_file_source):
# Move it. # Move it.
shutil.move(path_lyrics, os.path.splitext(file_media_dst)[0] + EXTENSION_LYRICS) shutil.move(path_file_source, path_file_destination)
result = True result = True
else: else:
@@ -341,20 +350,64 @@ class Download:
return result return result
def lyrics_write_file(self, dir_destination: pathlib.Path, lyrics: str) -> str: def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: str) -> bool:
# Build tmp lyrics filename
path_file_lyrics: str = os.path.splitext(file_media_dst)[0] + EXTENSION_LYRICS
result: bool = self._move_file(path_lyrics, path_file_lyrics)
return result
def _move_cover(self, path_cover: pathlib.Path, file_media_dst: str) -> bool:
# Build tmp lyrics filename
path_file_cover: pathlib.Path = pathlib.Path(file_media_dst).parent.absolute() / COVER_NAME
result: bool = self._move_file(path_cover, path_file_cover)
return result
def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str:
return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics)
def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str:
return self.write_to_tmp_file(dir_destination, mode="xb", content=image)
def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str:
result: str = dir_destination / str(uuid4()) result: str = dir_destination / str(uuid4())
encoding: str | None = "utf-8" if isinstance(content, str) else None
try: try:
with open(result, "x", encoding="utf-8") as f: with open(result, mode=mode, encoding=encoding) as f:
f.write(lyrics) f.write(content)
except: except:
result = "" result = ""
return result return result
def metadata_write(self, track: Track, path_media: str) -> (bool, pathlib.Path | None): @staticmethod
def cover_data(url: str = None, path_file: str = None) -> str | bytes:
result: str | bytes = ""
if url:
try:
result = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC).content
except Exception as e:
# TODO: Implement propper logging.
print(e)
elif path_file:
try:
with open(path_file, "rb") as f:
result = f.read()
except OSError as e:
# TODO: Implement propper logging.
print(e)
return result
def metadata_write(
self, track: Track, path_media: str, is_parent_album: str
) -> (bool, pathlib.Path | None, pathlib.Path | None):
result: bool = False result: bool = False
path_lyrics: pathlib.Path | None = None path_lyrics: pathlib.Path | None = None
path_cover: pathlib.Path | None = None
release_date: str = ( release_date: str = (
track.album.available_release_date.strftime("%Y-%m-%d") track.album.available_release_date.strftime("%Y-%m-%d")
if track.album.available_release_date if track.album.available_release_date
@@ -363,6 +416,7 @@ class Download:
copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else "" copy_right: str = track.copyright if hasattr(track, "copyright") and track.copyright else ""
isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else "" isrc: str = track.isrc if hasattr(track, "isrc") and track.isrc else ""
lyrics: str = "" lyrics: str = ""
cover_data: bytes = None
if self.settings.data.lyrics_embed or self.settings.data.lyrics_file: if self.settings.data.lyrics_embed or self.settings.data.lyrics_file:
# Try to retrieve lyrics. # Try to retrieve lyrics.
@@ -379,7 +433,14 @@ class Download:
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.") print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
if lyrics and self.settings.data.lyrics_file: if lyrics and self.settings.data.lyrics_file:
path_lyrics = self.lyrics_write_file(pathlib.Path(path_media).parent, lyrics) path_lyrics = self.lyrics_to_file(pathlib.Path(path_media).parent, lyrics)
if self.settings.data.metadata_cover_embed or (self.settings.data.cover_album_file and is_parent_album):
url_cover = track.album.image(int(self.settings.data.metadata_cover_dimension))
cover_data = self.cover_data(url=url_cover)
if cover_data and self.settings.data.cover_album_file and is_parent_album:
path_cover = self.cover_to_file(pathlib.Path(path_media).parent, cover_data)
# `None` values are not allowed. # `None` values are not allowed.
m: Metadata = Metadata( m: Metadata = Metadata(
@@ -396,14 +457,14 @@ class Download:
totaltrack=track.album.num_tracks if track.album and track.album.num_tracks else 1, totaltrack=track.album.num_tracks if track.album and track.album.num_tracks else 1,
totaldisc=track.album.num_volumes if track.album and track.album.num_volumes else 1, totaldisc=track.album.num_volumes if track.album and track.album.num_volumes else 1,
discnumber=track.volume_num if track.volume_num else 1, discnumber=track.volume_num if track.volume_num else 1,
url_cover=track.album.image(int(self.settings.data.metadata_cover_dimension)), cover_data=cover_data if self.settings.data.metadata_cover_embed else None,
) )
m.save() m.save()
result = True result = True
return result, path_lyrics return result, path_lyrics, path_cover
def items( def items(
self, self,
@@ -451,16 +512,19 @@ class Download:
f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout
) )
is_album: bool = isinstance(media, Album)
# Iterate through list items # Iterate through list items
while not self.progress.finished: while not self.progress.finished:
for media in items: for item_media in items:
# Download the item. # Download the item.
status_download, result_path_file = self.item( status_download, result_path_file = self.item(
media=media, media=item_media,
file_template=file_name_relative, file_template=file_name_relative,
quality_audio=quality_audio, quality_audio=quality_audio,
quality_video=quality_video, quality_video=quality_video,
download_delay=download_delay, download_delay=download_delay,
is_parent_album=is_album,
) )
# Advance progress bar. # Advance progress bar.
+1 -2
View File
@@ -31,7 +31,6 @@ from tidal_dl_ng.helper.tidal import (
search_results_all, search_results_all,
user_media_lists, user_media_lists,
) )
from tidal_dl_ng.metadata import Metadata
try: try:
import qdarktheme import qdarktheme
@@ -757,7 +756,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
if cover_url and self.cover_url_current != cover_url: if cover_url and self.cover_url_current != cover_url:
self.cover_url_current = cover_url self.cover_url_current = cover_url
data_cover: bytes = Metadata.cover_data(cover_url) data_cover: bytes = Download.cover_data(cover_url)
pixmap: QtGui.QPixmap = QtGui.QPixmap() pixmap: QtGui.QPixmap = QtGui.QPixmap()
pixmap.loadFromData(data_cover) pixmap.loadFromData(data_cover)
self.l_pm_cover.setPixmap(pixmap) self.l_pm_cover.setPixmap(pixmap)
+7 -33
View File
@@ -1,10 +1,7 @@
import mutagen import mutagen
import requests
from mutagen import flac, id3, mp4 from mutagen import flac, id3, mp4
from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, USLT from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, USLT
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC
class Metadata: class Metadata:
path_file: str path_file: str
@@ -22,7 +19,7 @@ class Metadata:
isrc: str isrc: str
lyrics: str lyrics: str
path_cover: str path_cover: str
url_cover: str cover_data: bytes
m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC
def __init__( def __init__(
@@ -41,8 +38,7 @@ class Metadata:
albumartist: str = "", albumartist: str = "",
date: str = "", date: str = "",
lyrics: str = "", lyrics: str = "",
path_cover: str = "", cover_data: bytes = None,
url_cover: str = "",
): ):
self.path_file = path_file self.path_file = path_file
self.title = title self.title = title
@@ -58,27 +54,25 @@ class Metadata:
self.composer = composer self.composer = composer
self.isrc = isrc self.isrc = isrc
self.lyrics = lyrics self.lyrics = lyrics
self.path_cover = path_cover self.cover_data = cover_data
self.url_cover = url_cover
self.m: mutagen.mp4.MP4 | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file) self.m: mutagen.mp4.MP4 | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file)
def _cover(self) -> bool: def _cover(self) -> bool:
result: bool = False result: bool = False
data_cover: str | bytes = self.cover_data(url=self.url_cover, path_file=self.path_cover)
if data_cover: if self.cover_data:
if isinstance(self.m, mutagen.flac.FLAC): if isinstance(self.m, mutagen.flac.FLAC):
flac_cover = flac.Picture() flac_cover = flac.Picture()
flac_cover.type = id3.PictureType.COVER_FRONT flac_cover.type = id3.PictureType.COVER_FRONT
flac_cover.data = data_cover flac_cover.data = self.cover_data
flac_cover.mime = "image/jpeg" flac_cover.mime = "image/jpeg"
self.m.clear_pictures() self.m.clear_pictures()
self.m.add_picture(flac_cover) self.m.add_picture(flac_cover)
elif isinstance(self.m, mutagen.mp3.MP3): elif isinstance(self.m, mutagen.mp3.MP3):
self.m.tags.add(APIC(encoding=3, data=data_cover)) self.m.tags.add(APIC(encoding=3, data=self.cover_data))
elif isinstance(self.m, mutagen.mp4.MP4): elif isinstance(self.m, mutagen.mp4.MP4):
cover_mp4 = mp4.MP4Cover(data_cover) cover_mp4 = mp4.MP4Cover(self.cover_data)
self.m.tags["covr"] = [cover_mp4] self.m.tags["covr"] = [cover_mp4]
result = True result = True
@@ -142,23 +136,3 @@ class Metadata:
self.m.tags["\xa9wrt"] = ", ".join(self.composer) if self.composer else "" self.m.tags["\xa9wrt"] = ", ".join(self.composer) if self.composer else ""
self.m.tags["\xa9lyr"] = self.lyrics self.m.tags["\xa9lyr"] = self.lyrics
self.m.tags["isrc"] = self.isrc self.m.tags["isrc"] = self.isrc
@staticmethod
def cover_data(url: str = None, path_file: str = None) -> str | bytes:
result: str | bytes = ""
if url:
try:
result = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC).content
except Exception as e:
# TODO: Implement propper logging.
print(e)
elif path_file:
try:
with open(path_file, "rb") as f:
result = f.read()
except OSError as e:
# TODO: Implement propper logging.
print(e)
return result
+4 -2
View File
@@ -10,8 +10,6 @@ from tidal_dl_ng.constants import CoverDimensions, QualityVideo, SkipExisting
@dataclass @dataclass
class Settings: class Settings:
skip_existing: SkipExisting = SkipExisting.Disabled skip_existing: SkipExisting = SkipExisting.Disabled
# TODO: Implement cover download to a separate file.
# album_cover_save: bool = True
lyrics_embed: bool = False lyrics_embed: bool = False
lyrics_file: bool = False lyrics_file: bool = False
# TODO: Implement API KEY selection. # TODO: Implement API KEY selection.
@@ -36,6 +34,8 @@ class Settings:
video_convert_mp4: bool = True video_convert_mp4: bool = True
path_binary_ffmpeg: str = "" path_binary_ffmpeg: str = ""
metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320 metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320
metadata_cover_embed: bool = True
cover_album_file: bool = True
extract_flac: bool = True extract_flac: bool = True
@@ -79,6 +79,8 @@ class HelpSettings:
metadata_cover_dimension: str = ( metadata_cover_dimension: str = (
"The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640x 1280x1280." "The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640x 1280x1280."
) )
metadata_cover_embed: str = "Embed album cover into file."
cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded."
extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)." extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)."