From 55a4ee664696b3b5de7d83f80e1ba6f7a3eb24ce Mon Sep 17 00:00:00 2001 From: Colin Coe Date: Sat, 6 Jan 2024 12:47:32 -0700 Subject: [PATCH] Bugfix: Empty Sony 360 Reality Audio Files (#33) * Fix errors arisen from 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' * Clean up based on flake8 suggestions * Make some mypy-inspired changes * Fix empty files with Sony 360 Reality Audio codec * Update docker-image.yml to only build on releases * Edit docker-image.yml, following https://docs.github.com/en/actions/ --- .github/workflows/docker-image.yml | 44 +++- pyinstall.py | 3 + release_artifacts.sh | 18 +- tidal_wave/album.py | 8 +- tidal_wave/artist.py | 26 +- tidal_wave/dash.py | 5 +- tidal_wave/hls.py | 8 +- tidal_wave/login.py | 5 +- tidal_wave/main.py | 7 +- tidal_wave/media.py | 4 +- tidal_wave/mix.py | 14 +- tidal_wave/models.py | 5 +- tidal_wave/oauth.py | 3 - tidal_wave/playlist.py | 14 +- tidal_wave/requesting.py | 388 ++++++++++++++++------------- tidal_wave/track.py | 53 ++-- tidal_wave/utils.py | 31 --- tidal_wave/video.py | 23 +- 18 files changed, 363 insertions(+), 296 deletions(-) create mode 100644 pyinstall.py diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 70110f7..9c479f1 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,18 +1,46 @@ +# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages name: Docker Image CI on: + release: + types: ["published"] push: - branches: [ "trunk" ] - pull_request: - branches: [ "trunk" ] + branches: ["trunk"] -jobs: +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} - build: +jobs: + build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: - - uses: actions/checkout@v3 - - name: Build the Docker image - run: docker build . --file Dockerfile --tag ghcr.io/ebb-earl-co/tidal-wave:trunk + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/pyinstall.py b/pyinstall.py new file mode 100644 index 0000000..afa3531 --- /dev/null +++ b/pyinstall.py @@ -0,0 +1,3 @@ +from tidal_wave.main import app + +app() diff --git a/release_artifacts.sh b/release_artifacts.sh index 139c83c..e68b9e1 100644 --- a/release_artifacts.sh +++ b/release_artifacts.sh @@ -2,6 +2,7 @@ VERSION="${1:-latest}" command -v python3 >/dev/null 2>&1 || { echo >&2 "I require Python3 but it's not installed. Aborting."; exit 1; } +PY3VERSION="$(command -v python3)" -c "import sys;print('.'.join(map(str, sys.version_info[:2])))" if ! command -v cargo &> /dev/null; then @@ -11,10 +12,25 @@ fi /usr/bin/env python3 -m venv ./venv && \ source ./venv/bin/activate && \ python3 -m pip install --upgrade pip setuptools wheel && \ - python3 -m pip install build shiv twine && \ + python3 -m pip install -r requirements.txt && \ + python3 -m pip install build pyinstaller shiv twine && \ python3 -m shiv --compressed --reproducible -c tidal-wave -o ~/tools/tidal-wave_${VERSION}.pyz . && \ # shiv executable PYAPP_PROJECT_NAME=tidal-wave PYAPP_PROJECT_VERSION=${VERSION} cargo install pyapp --root out && \ mv out/bin/pyapp ~/tools/tidal-wave_${VERSION}.pyapp && \ PYAPP_PROJECT_NAME=tidal-wave PYAPP_PROJECT_VERSION=${VERSION} PYAPP_FULL_ISOLATION=1 PYAPP_DISTRIBUTION_EMBED=1 PYAPP_PYTHON_VERSION=3.11 cargo install pyapp --root out && \ mv out/bin/pyapp ~/tools/tidal-wave_${VERSION}_py311.pyapp && \ rm -r out/ + +# Pyinstaller +# TODO: figure out how to bundle FFmpeg legally; +# i.e., what are the License ramifications for this project +python3 -m pyinstaller \ + --distpath ./.dist/ \ + --workpath ./.build/ \ + --onefile \ + --name tidal-wave_${VERSION} + --paths tidal_wave \ + --paths ./venv/lib/python${PY3VERSION}/site-packages/ \ + # --add-data "./ffmpeg/*:./ffmpeg/" \ + ./pyinstall.py && \ + rm -r ./.dist/ ./.build/ diff --git a/tidal_wave/album.py b/tidal_wave/album.py index 2b2f45a..46b404f 100644 --- a/tidal_wave/album.py +++ b/tidal_wave/album.py @@ -7,7 +7,11 @@ from requests import Session from .media import AudioFormat -from .models import AlbumsEndpointResponseJSON +from .models import ( + AlbumsEndpointResponseJSON, + AlbumsItemsResponseJSON, + AlbumsReviewResponseJSON, +) from .requesting import request_albums, request_album_items, request_album_review from .track import Track from .utils import download_cover_image @@ -34,8 +38,6 @@ def get_metadata(self, session: Session): ) def get_review(self, session: Session): - if self.album_dir is None: - self.set_dir(out_dir=out_dir) self.album_review: Optional[AlbumsReviewResponseJSON] = request_album_review( session=session, identifier=self.album_id ) diff --git a/tidal_wave/artist.py b/tidal_wave/artist.py index bd82c91..029eca1 100644 --- a/tidal_wave/artist.py +++ b/tidal_wave/artist.py @@ -1,24 +1,24 @@ -from dataclasses import dataclass, field -import json +from dataclasses import dataclass import logging from pathlib import Path -import shutil -import sys -from types import SimpleNamespace -from typing import Dict, List, Optional, Tuple, Union +from typing import List, Optional -from requests import HTTPError, Session +from requests import Session from .album import Album from .media import AudioFormat +from .models import ( + ArtistsAlbumsResponseJSON, + ArtistsEndpointResponseJSON, + ArtistsVideosResponseJSON, +) from .requesting import ( request_artists, request_artists_albums, request_artists_audio_works, request_artists_videos, ) -from .track import Track -from .utils import download_cover_image, TIDAL_API_URL +from .utils import download_cover_image from .video import Video logger = logging.getLogger("__name__") @@ -32,7 +32,7 @@ def set_metadata(self, session: Session): """This function requests from TIDAL API endpoint /artists and stores the results in self.metadata""" self.metadata: Optional[ArtistsEndpointResponseJSON] = request_artists( - session=session, identifier=self.artist_id + session, self.artist_id ) def save_artist_image(self, session: Session): @@ -46,21 +46,21 @@ def set_albums(self, session: Session): """This function requests from TIDAL API endpoint /artists/albums and stores the results in self.albums""" self.albums: Optional[ArtistsAlbumsResponseJSON] = request_artists_albums( - session=session, identifier=self.artist_id + session, self.artist_id ) def set_audio_works(self, session: Session): """This function requests from TIDAL API endpoint /artists/albums?filter=EPSANDSINGLES and stores the results in self.albums""" self.albums: Optional[ArtistsAlbumsResponseJSON] = request_artists_audio_works( - session=session, identifier=self.artist_id + session, self.artist_id ) def set_videos(self, session: Session): """This function requests from TIDAL API endpoint /artists/videos and stores the results in self.albums""" self.videos: Optional[ArtistsVideosResponseJSON] = request_artists_videos( - session=session, identifier=self.artist_id + session, self.artist_id ) def set_dir(self, out_dir: Path): diff --git a/tidal_wave/dash.py b/tidal_wave/dash.py index 5a0ded6..d5dd6c2 100644 --- a/tidal_wave/dash.py +++ b/tidal_wave/dash.py @@ -1,10 +1,7 @@ -#!/usr/bin/env python3 -#! -*- coding: utf-8 -*- - from dataclasses import dataclass, field import json import re -from typing import Literal, List, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union from xml.etree import ElementTree as ET import dataclass_wizard diff --git a/tidal_wave/hls.py b/tidal_wave/hls.py index ddd1545..86ddc49 100644 --- a/tidal_wave/hls.py +++ b/tidal_wave/hls.py @@ -1,7 +1,7 @@ import json import logging -from requests import Response, Session -from typing import List, Optional, Union +from requests import Session +from typing import Dict, List, Optional, Union from .models import VideosEndpointStreamResponseJSON @@ -29,7 +29,9 @@ def download( return response.text, response.url -def playlister(session: Session, vesrj: VideosEndpointStreamResponseJSON) -> m3u8.M3U8: +def playlister( + session: Session, vesrj: Optional[VideosEndpointStreamResponseJSON] +) -> m3u8.M3U8: """Attempts to parse a VideosEndpointStreamResponseJSON object into an m3u8.M3U8 object. Requires fetching HTTP(s) resources, so takes a requests.Session object as an argument. If error occurs, raises diff --git a/tidal_wave/login.py b/tidal_wave/login.py index 38ea7eb..47efc3e 100644 --- a/tidal_wave/login.py +++ b/tidal_wave/login.py @@ -57,9 +57,7 @@ def load_token_from_disk( try: bearer_token_json: dict = json.loads(decoded_token_file_contents) except json.decoder.JSONDecodeError: - logger.warning( - f"File '{path_to_token_file.absolute()}' cannot be parsed as JSON" - ) + logger.warning(f"File '{token_path.absolute()}' cannot be parsed as JSON") return else: return bearer_token_json.get("access_token") @@ -199,7 +197,6 @@ def login_windows( logger.critical("Access token is not valid: exiting now.") else: logger.debug(f"Writing this access token to '{str(token_path.absolute())}'") - # s.headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) TIDAL/2.35.0 Chrome/108.0.5359.215 Electron/22.3.27 Safari/537.36" s.headers["User-Agent"] = "TIDAL_NATIVE_PLAYER/WIN/3.1.2.195" s.params["deviceType"] = "DESKTOP" to_write: dict = { diff --git a/tidal_wave/main.py b/tidal_wave/main.py index 8330e30..3ff7bb1 100644 --- a/tidal_wave/main.py +++ b/tidal_wave/main.py @@ -1,11 +1,7 @@ -#!/usr/bin/env python3 -#! -*- coding: utf-8 -*- - from contextlib import closing import logging from pathlib import Path -import sys -from typing import Optional +from typing import Optional, Union from .login import login, AudioFormat, LogLevel from .album import Album @@ -23,7 +19,6 @@ TidalTrack, TidalVideo, ) -from .requesting import get_album_id from platformdirs import user_music_path import typer diff --git a/tidal_wave/media.py b/tidal_wave/media.py index c19641a..3a32cd9 100644 --- a/tidal_wave/media.py +++ b/tidal_wave/media.py @@ -1,7 +1,5 @@ from enum import Enum -import random -import time -from typing import Dict, Optional +from typing import Dict class AudioFormat(str, Enum): diff --git a/tidal_wave/mix.py b/tidal_wave/mix.py index b43e7ae..8d2c7c9 100644 --- a/tidal_wave/mix.py +++ b/tidal_wave/mix.py @@ -1,16 +1,20 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass import json import logging from pathlib import Path import shutil import sys from types import SimpleNamespace -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union from requests import HTTPError, Session from .media import AudioFormat -from .models import TracksEndpointResponseJSON, VideosEndpointResponseJSON +from .models import ( + PlaylistsEndpointResponseJSON, + TracksEndpointResponseJSON, + VideosEndpointResponseJSON, +) from .track import Track from .utils import TIDAL_API_URL from .video import Video @@ -234,7 +238,7 @@ def get(self, session: Session, audio_format: AudioFormat, out_dir: Path): self.save_cover_image(session, out_dir) try: self.save_description() - except: + except Exception: pass _get_items = self.get_items(session, audio_format) @@ -263,7 +267,7 @@ def request_mixes(session: Session, mix_id: str) -> Optional[SimpleNamespace]: except HTTPError as he: if resp.status_code == 404: logger.warning( - f"404 Client Error: not found for TIDAL API endpoint pages/mix" + "404 Client Error: not found for TIDAL API endpoint pages/mix" ) else: logger.exception(he) diff --git a/tidal_wave/models.py b/tidal_wave/models.py index fa815d0..5f8f9b6 100644 --- a/tidal_wave/models.py +++ b/tidal_wave/models.py @@ -614,7 +614,10 @@ class TidalPlaylist(TidalResource): url: str def __post_init__(self): - self.pattern: str = r"http(?:s)?://(?:listen\.)?tidal\.com/(?:browse/)?playlist/([0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})(?:.*?)?" + self.pattern: str = ( + r"http(?:s)?://(?:listen\.)?tidal\.com/(?:browse/)?playlist/" + r"([0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})(?:.*?)?" + ) _id = self.match_url() diff --git a/tidal_wave/oauth.py b/tidal_wave/oauth.py index 2450d1a..e04d746 100644 --- a/tidal_wave/oauth.py +++ b/tidal_wave/oauth.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python3 -#! -*- coding: utf-8 -*- - import base64 from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone diff --git a/tidal_wave/playlist.py b/tidal_wave/playlist.py index 4a47148..d7bfe0e 100644 --- a/tidal_wave/playlist.py +++ b/tidal_wave/playlist.py @@ -1,19 +1,23 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass import json import logging from pathlib import Path import shutil import sys from types import SimpleNamespace -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Set, Tuple, Union from requests import HTTPError, Session from .media import AudioFormat -from .models import TracksEndpointResponseJSON, VideosEndpointResponseJSON +from .models import ( + PlaylistsEndpointResponseJSON, + TracksEndpointResponseJSON, + VideosEndpointResponseJSON, +) from .requesting import request_playlists from .track import Track -from .utils import download_cover_image +from .utils import download_cover_image, TIDAL_API_URL from .video import Video logger = logging.getLogger("__name__") @@ -240,7 +244,7 @@ def get(self, session: Session, audio_format: AudioFormat, out_dir: Path): self.save_cover_image(session, out_dir) try: self.save_description() - except: + except Exception: pass _get_items = self.get_items(session, audio_format) diff --git a/tidal_wave/requesting.py b/tidal_wave/requesting.py index cc3041d..89a1999 100644 --- a/tidal_wave/requesting.py +++ b/tidal_wave/requesting.py @@ -1,6 +1,6 @@ from functools import partial import logging -from typing import Callable, Dict, Iterable, Iterator, Optional, Tuple, Union +from typing import Callable, Iterable, Iterator, Optional, Tuple, Union from .models import ( AlbumsEndpointResponseJSON, @@ -24,7 +24,7 @@ from .utils import TIDAL_API_URL import backoff -from requests import HTTPError, PreparedRequest, Request, Response, Session +from requests import HTTPError, Response, Session logger: logging.Logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def requester_maker( url_end: str = "", headers: Optional[dict] = None, parameters: Optional[dict] = None, - subclass: Optional[ResponseJSON] = None, + subclass: Optional["ResponseJSON"] = None, credits_flag: bool = False, ) -> Callable: """This function is a function factory: it crafts nearly identical @@ -127,192 +127,240 @@ def _get(s: Session, request_kwargs: dict) -> Response: # process (on the happy path) creates a requests.Session object. Identifier # varies with the media type, etc. -request_albums: Callable[ - [Session, int], Optional[AlbumsEndpointResponseJSON] -] = partial( - requester_maker, - endpoint="albums", - headers={"Accept": "application/json"}, - subclass=AlbumsEndpointResponseJSON, -) -request_album_items: Callable[ - [Session, int], Optional[AlbumsItemsResponseJSON] -] = partial( - requester_maker, - endpoint="albums", - headers={"Accept": "application/json"}, - parameters={"limit": 100}, - url_end="/items", - subclass=AlbumsItemsResponseJSON, -) +def request_albums( + session: Session, identifier: int +) -> Optional[AlbumsEndpointResponseJSON]: + return requester_maker( + session=session, + endpoint="albums", + identifier=identifier, + headers={"Accept": "application/json"}, + subclass=AlbumsEndpointResponseJSON, + ) -request_album_review: Callable[ - [Session, int], Optional[AlbumsItemsResponseJSON] -] = partial( - requester_maker, - endpoint="albums", - headers={"Accept": "application/json"}, - url_end="/review", - subclass=AlbumsReviewResponseJSON, -) -request_artist_bio: Callable[ - [Session, int], Optional[ArtistsBioResponseJSON] -] = partial( - requester_maker, - endpoint="artists", - headers={"Accept": "application/json"}, - url_end="/bio", - subclass=ArtistsBioResponseJSON, -) +def request_album_items( + session: Session, identifier: int +) -> Optional[AlbumsItemsResponseJSON]: + return requester_maker( + session=session, + endpoint="albums", + identifier=identifier, + headers={"Accept": "application/json"}, + parameters={"limit": 100}, + url_end="/items", + subclass=AlbumsItemsResponseJSON, + ) -request_artists: Callable[ - [Session, int], Optional[ArtistsEndpointResponseJSON] -] = partial( - requester_maker, - endpoint="artists", - headers={"Accept": "application/json"}, - subclass=ArtistsEndpointResponseJSON, -) -request_artists_albums: Callable[ - [Session, int], Optional[ArtistsAlbumsResponseJSON] -] = partial( - requester_maker, - endpoint="artists", - headers={"Accept": "application/json"}, - url_end="/albums", - subclass=ArtistsAlbumsResponseJSON, -) +def request_album_review( + session: Session, identifier: int +) -> Optional[AlbumsReviewResponseJSON]: + return requester_maker( + session=session, + endpoint="albums", + identifier=identifier, + headers={"Accept": "application/json"}, + url_end="/review", + subclass=AlbumsReviewResponseJSON, + ) -request_artists_audio_works: Callable[ - [Session, int], Optional[ArtistsAlbumsResponseJSON] -] = partial( - requester_maker, - endpoint="artists", - headers={"Accept": "application/json"}, - parameters={"filter": "EPSANDSINGLES"}, - url_end="/albums", - subclass=ArtistsAlbumsResponseJSON, -) -request_artists_videos: Callable[ - [Session, int], Optional[ArtistsAlbumsResponseJSON] -] = partial( - requester_maker, - endpoint="artists", - headers={"Accept": "application/json"}, - url_end="/videos", - subclass=ArtistsVideosResponseJSON, -) +def request_artist_bio( + session: Session, identifier: int +) -> Optional[ArtistsBioResponseJSON]: + return requester_maker( + session=session, + endpoint="artists", + identifier=identifier, + headers={"Accept": "application/json"}, + url_end="/bio", + subclass=ArtistsBioResponseJSON, + ) + + +def request_artists( + session: Session, identifier: int +) -> Optional[ArtistsEndpointResponseJSON]: + return requester_maker( + session=session, + endpoint="artists", + identifier=identifier, + headers={"Accept": "application/json"}, + subclass=ArtistsEndpointResponseJSON, + ) + + +def request_artists_albums( + session: Session, identifier: int +) -> Optional[ArtistsAlbumsResponseJSON]: + return requester_maker( + session=session, + endpoint="artists", + identifier=identifier, + headers={"Accept": "application/json"}, + url_end="/albums", + subclass=ArtistsAlbumsResponseJSON, + ) + + +def request_artists_audio_works( + session: Session, identifier: int +) -> Optional[ArtistsAlbumsResponseJSON]: + return requester_maker( + session=session, + endpoint="artists", + identifier=identifier, + headers={"Accept": "application/json"}, + parameters={"filter": "EPSANDSINGLES"}, + url_end="/albums", + subclass=ArtistsAlbumsResponseJSON, + ) + + +def request_artists_videos( + session: Session, identifier: int +) -> Optional[ArtistsVideosResponseJSON]: + return requester_maker( + session=session, + endpoint="artists", + identifier=identifier, + headers={"Accept": "application/json"}, + url_end="/videos", + subclass=ArtistsVideosResponseJSON, + ) + + +def request_tracks( + session: Session, identifier: int +) -> Optional[TracksEndpointResponseJSON]: + return requester_maker( + session=session, + endpoint="tracks", + identifier=identifier, + headers={"Accept": "application/json"}, + subclass=TracksEndpointResponseJSON, + ) -request_tracks: Callable[ - [Session, int], Optional[TracksEndpointResponseJSON] -] = partial( - requester_maker, - endpoint="tracks", - headers={"Accept": "application/json"}, - subclass=TracksEndpointResponseJSON, -) # This one's special, because its JSON response isn't proper JSON: # it's just an array of JSON objects, so we have to pass a flag to mark # that the logic common to the rest of the functions is slightly different here. -request_credits: Callable[ - [Session, int], Optional[TracksCreditsResponseJSON] -] = partial( - requester_maker, - endpoint="tracks", - headers={"Accept": "application/json"}, - parameters={"includeContributors": True}, - url_end="/credits", - subclass=TracksCreditsResponseJSON, - credits_flag=True, -) +def request_credits( + session: Session, identifier: int +) -> Optional[TracksCreditsResponseJSON]: + return requester_maker( + session=session, + endpoint="tracks", + identifier=identifier, + headers={"Accept": "application/json"}, + parameters={"includeContributors": True}, + url_end="/credits", + subclass=TracksCreditsResponseJSON, + credits_flag=True, + ) -request_lyrics: Callable[[Session, int], Optional[TracksLyricsResponseJSON]] = partial( - requester_maker, - endpoint="tracks", - headers={"Accept": "application/json"}, - url_end="/lyrics", - subclass=TracksLyricsResponseJSON, -) -# One more layer of currying here, as the parameters argument -# is dependent on a runtime variable. -request_stream: Callable[ - [Session, int, str], Optional[TracksEndpointStreamResponseJSON] -] = lambda session, track_id, audio_quality: partial( - requester_maker, - session=session, - identifier=track_id, - endpoint="tracks", - headers={"Accept": "application/json"}, - parameters={ - "audioquality": audio_quality, - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, - url_end="/playbackinfopostpaywall", - subclass=TracksEndpointStreamResponseJSON, -)() - -request_videos: Callable[ - [Session, int], Optional[VideosEndpointResponseJSON] -] = partial( - requester_maker, - endpoint="videos", - headers={"Accept": "application/json"}, - subclass=VideosEndpointResponseJSON, -) +def request_lyrics( + session: Session, identifier: int +) -> Optional[TracksLyricsResponseJSON]: + return requester_maker( + session=session, + endpoint="tracks", + identifier=identifier, + headers={"Accept": "application/json"}, + url_end="/lyrics", + subclass=TracksLyricsResponseJSON, + ) -request_video_contributors: Callable[ - [Session, int], Optional[VideosContributorsResponseJSON] -] = partial( - requester_maker, - endpoint="videos", - headers={"Accept": "application/json"}, - parameters={"limit": 100}, - url_end="/contributors", - subclass=VideosContributorsResponseJSON, -) # One more layer of currying here, as the parameters argument # is dependent on a runtime variable. -request_video_stream: Callable[ - [Session, int, str], Optional[VideosEndpointStreamResponseJSON] -] = lambda session, video_id, video_quality: partial( - requester_maker, - session=session, - identifier=video_id, - endpoint="videos", - headers={"Accept": "application/json"}, - parameters={ - "videoquality": video_quality, - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, - url_end="/playbackinfopostpaywall", - subclass=VideosEndpointStreamResponseJSON, -)() - -request_playlists: Callable[ - [Session, int], Optional[PlaylistsEndpointResponseJSON] -] = partial( - requester_maker, - endpoint="playlists", - headers={"Accept": "application/json"}, - subclass=PlaylistsEndpointResponseJSON, -) + + +def request_stream( + session: Session, track_id: int, audio_quality: str +) -> Optional[TracksEndpointStreamResponseJSON]: + func = partial( + requester_maker, + session=session, + endpoint="tracks", + identifier=track_id, + headers={"Accept": "application/json"}, + parameters={ + "audioquality": audio_quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + url_end="/playbackinfopostpaywall", + subclass=TracksEndpointStreamResponseJSON, + ) + return func() + + +def request_videos( + session: Session, identifier: int +) -> Optional[VideosEndpointResponseJSON]: + return requester_maker( + session=session, + endpoint="videos", + identifier=identifier, + headers={"Accept": "application/json"}, + subclass=VideosEndpointResponseJSON, + ) + + +def request_video_contributors( + session: Session, identifier: int +) -> Optional[VideosContributorsResponseJSON]: + return requester_maker( + session=session, + endpoint="videos", + identifier=identifier, + headers={"Accept": "application/json"}, + parameters={"limit": 100}, + url_end="/contributors", + subclass=VideosContributorsResponseJSON, + ) + + +def request_video_stream( + session: Session, video_id: int, video_quality: str +) -> Optional[VideosEndpointStreamResponseJSON]: + func = partial( + requester_maker, + session=session, + identifier=video_id, + endpoint="videos", + headers={"Accept": "application/json"}, + parameters={ + "videoquality": video_quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + url_end="/playbackinfopostpaywall", + subclass=VideosEndpointStreamResponseJSON, + ) + return func() + + +def request_playlists( + session: Session, identifier: int +) -> Optional[PlaylistsEndpointResponseJSON]: + return requester_maker( + session=session, + endpoint="playlists", + identifier=identifier, + headers={"Accept": "application/json"}, + subclass=PlaylistsEndpointResponseJSON, + ) def get_album_id(session: Session, track_id: int) -> Optional[int]: """Given the Tidal ID to a track, query the Tidal API in order to retrieve the Tidal ID of the album to which the track belongs""" - terj: Optional[TracksEndpointResponseJSON] = request_tracks( - session=session, identifier=track_id - ) + terj: Optional[TracksEndpointResponseJSON] = request_tracks(session, track_id) album_id: Optional[int] = None try: @@ -323,7 +371,7 @@ def get_album_id(session: Session, track_id: int) -> Optional[int]: return album_id -def contiguous_ranges(value: int, range_size: int) -> Iterator[Tuple[int]]: +def contiguous_ranges(value: int, range_size: int) -> Iterator[Tuple[int, int]]: """This function is a generator: it yields two-tuples of int, with the tuples representing the (inclusive) boundaries of ranges of size range_size. The final tuple will represent a range <= range_size if @@ -336,7 +384,7 @@ def contiguous_ranges(value: int, range_size: int) -> Iterator[Tuple[int]]: i: int = 0 rs: int = range_size - 1 while i + rs < value: - t: Tuple[int] = (i, i + rs) + t: Tuple[int, int] = (i, i + rs) i = t[-1] + 1 yield t else: @@ -359,7 +407,7 @@ def http_request_range_headers( 'bytes=15-16') ``` """ - ranges: Iterator[Tuple[int]] = contiguous_ranges(content_length, range_size) + ranges: Iterator[Tuple[int, int]] = contiguous_ranges(content_length, range_size) iterable: Iterable = (f"bytes={t[0]}-{t[1]}" for t in ranges) if return_tuple: return tuple(iterable) @@ -367,7 +415,7 @@ def http_request_range_headers( return iterable -def fetch_content_length(session: Session, url: str) -> dict: +def fetch_content_length(session: Session, url: str) -> int: """Attempt to get the amount of bytes pointed to by `url`. If the HEAD request from the requests.Session object, `session`, encounters an HTTP request; or if the server does not support diff --git a/tidal_wave/track.py b/tidal_wave/track.py index fb7ed5f..b2f03b4 100644 --- a/tidal_wave/track.py +++ b/tidal_wave/track.py @@ -7,14 +7,14 @@ import shutil import subprocess import sys -from typing import Optional +from typing import Dict, Iterable, List, Optional import mutagen from mutagen.mp4 import MP4Cover import ffmpeg -from requests import Request, Session +from requests import Session -from .dash import manifester, JSONDASHManifest, XMLDASHManifest +from .dash import manifester, JSONDASHManifest, Manifest, XMLDASHManifest from .media import af_aq, AudioFormat, TAG_MAPPING from .models import ( AlbumsEndpointResponseJSON, @@ -34,7 +34,7 @@ request_stream, request_tracks, ) -from .utils import download_cover_image, temporary_file +from .utils import download_artist_image, download_cover_image, temporary_file logger = logging.getLogger("__name__") @@ -48,37 +48,25 @@ def __post_init__(self): self.tags: dict = {} self.album_cover_saved: bool = False - def _lookup(self, af) -> AudioFormat: - af_aq: Dict[AudioFormat, str] = { - AudioFormat.sony_360_reality_audio: "LOW", - AudioFormat.dolby_atmos: "LOW", - AudioFormat.hi_res: "HI_RES", - AudioFormat.mqa: "HI_RES", - AudioFormat.lossless: "LOSSLESS", - AudioFormat.high: "HIGH", - AudioFormat.low: "LOW", - } - return af_aq.get(af) - def get_metadata(self, session: Session): self.metadata: Optional[TracksEndpointResponseJSON] = request_tracks( - session=session, identifier=self.track_id + session, self.track_id ) def get_album(self, session: Session): self.album: Optional[AlbumsEndpointResponseJSON] = request_albums( - session=session, identifier=self.metadata.album.id + session, self.metadata.album.id ) def get_credits(self, session: Session): self.credits: Optional[TracksCreditsResponseJSON] = request_credits( - session=session, identifier=self.track_id + session, self.track_id ) def get_lyrics(self, session: Session): if self._has_lyrics is None: self.lyrics: Optional[TracksLyricsResponseJSON] = request_lyrics( - session=session, identifier=self.track_id + session, self.track_id ) if self.lyrics is None: self._has_lyrics = False @@ -89,9 +77,9 @@ def get_lyrics(self, session: Session): def get_stream(self, session: Session, audio_format: AudioFormat): """Populates self.stream, self.manifest""" - aq: Optional[str] = self._lookup(audio_format) + aq: Optional[str] = af_aq.get(audio_format) self.stream: Optional[TracksEndpointStreamResponseJSON] = request_stream( - session=session, track_id=self.track_id, audio_quality=aq + session, self.track_id, aq ) def set_manifest(self): @@ -207,7 +195,7 @@ def save_artist_bio(self, session: Session): track_artist_bio_json: Path = self.album_dir / f"{a.name}-bio.json" if not track_artist_bio_json.exists(): artist_bio: Optional[ArtistsBioResponseJSON] = request_artist_bio( - session=session, identifier=a.id + session, a.id ) if artist_bio is not None: logger.info( @@ -393,12 +381,21 @@ def set_tags(self): self.mutagen["covr"] = [ MP4Cover(self.cover_path.read_bytes(), imageformat=MP4Cover.FORMAT_JPEG) ] + elif self.codec == "mka": + # FFmpeg chokes here with + # [matroska @ 0x5eb6a424f840] No wav codec tag found for codec none + # so DON'T attempt to add a cover image, and DON'T run the + # FFmpeg to put streams in order + self.mutagen.save() + return + self.mutagen.save() # Make sure audio track comes first because of # less-sophisticated audio players with temporary_file(suffix=".mka") as tf: cmd: List[str] = shlex.split( - f"""ffmpeg -hide_banner -loglevel quiet -y -i "{str(self.outfile.absolute())}" -map 0:a:0 -map 0:v:0 -c copy "{tf.name}" """ + f"""ffmpeg -hide_banner -loglevel quiet -y -i "{str(self.outfile.absolute())}" + -map 0:a:0 -map 0:v:0 -c copy "{tf.name}" """ ) subprocess.run(cmd) shutil.copyfile(tf.name, str(self.outfile.absolute())) @@ -472,19 +469,19 @@ def get( try: self.get_lyrics(session) - except: + except Exception: pass self.save_album_cover(session) try: self.save_artist_image(session) - except: + except Exception: pass try: self.save_artist_bio(session) - except: + except Exception: pass if self.download(session, out_dir) is None: @@ -504,6 +501,7 @@ def dump(self, fp=sys.stdout): else: v: Optional[str] = str(self.outfile.absolute()) json.dump({k: v}, fp) + return None def dumps(self) -> str: k: int = int(self.metadata.track_number) @@ -514,3 +512,4 @@ def dumps(self) -> str: else: v: Optional[str] = str(self.outfile.absolute()) json.dumps({k: v}) + return None diff --git a/tidal_wave/utils.py b/tidal_wave/utils.py index 607de03..cfe2d99 100644 --- a/tidal_wave/utils.py +++ b/tidal_wave/utils.py @@ -89,37 +89,6 @@ def download_artist_image( return output_file -def download_artist_bio( - session: Session, artist: Artist, output_dir: Path -) -> Optional[Path]: - """Given a UUID that corresponds to a (JPEG) image on Tidal's servers, - download the image file and write it as '{artist name}.jpeg' - in the directory `output_dir`. Returns path to downloaded file""" - artist_bio: Optional[ArtistsBioResponseJSON] = request_ - with session.get(url=_url, headers={"Accept": "image/jpeg"}) as r: - if not r.ok: - logger.warning( - "Could not retrieve data from Tidal resources/images URL " - f"for artist {artist} due to error code: {r.status_code}" - ) - logger.debug(r.reason) - return - else: - bytes_to_write = BytesIO(r.content) - - file_name: str = f"{artist.name.replace('..', '')}.jpg" - if bytes_to_write is not None: - output_file: Path = output_dir / file_name - bytes_to_write.seek(0) - output_file.write_bytes(bytes_to_write.read()) - bytes_to_write.close() - logger.info( - f"Wrote artist image JPEG for {artist} to " - f"'{str(output_file.absolute())}'" - ) - return output_file - - @contextmanager def temporary_file(suffix: str = ".mka"): """This context-managed function is a stand-in for diff --git a/tidal_wave/video.py b/tidal_wave/video.py index 766954f..c6c4e84 100644 --- a/tidal_wave/video.py +++ b/tidal_wave/video.py @@ -3,10 +3,15 @@ import logging from pathlib import Path import sys -from typing import Optional +from typing import Dict, List, Optional -from .hls import playlister, variant_streams +from .hls import playlister, variant_streams, TidalM3U8Exception from .media import TAG_MAPPING, VideoFormat +from .models import ( + VideosContributorsResponseJSON, + VideosEndpointResponseJSON, + VideosEndpointStreamResponseJSON, +) from .requesting import request_videos, request_video_contributors, request_video_stream from .utils import temporary_file @@ -29,20 +34,20 @@ def __post_init__(self): def get_metadata(self, session: Session): """Request from TIDAL API /videos endpoint""" self.metadata: Optional[VideosEndpointResponseJSON] = request_videos( - session=session, identifier=self.video_id + session, self.video_id ) def get_contributors(self, session: Session): """Request from TIDAL API /videos/contributors endpoint""" self.contributors: Optional[ VideosContributorsResponseJSON - ] = request_video_contributors(session=session, identifier=self.video_id) + ] = request_video_contributors(session, self.video_id) def get_stream(self, session: Session, video_format=VideoFormat.high): """Populates self.stream by requesting from TIDAL API /videos/playbackinfopostpaywall endpoint""" self.stream: Optional[VideosEndpointStreamResponseJSON] = request_video_stream( - session=session, video_id=self.video_id, video_quality=video_format.value + session, self.video_id, video_format.value ) def get_m3u8(self, session: Session): @@ -200,22 +205,22 @@ def get( # check for 404 error with metadata if self.metadata is None: - return + return None self.get_contributors(session) self.get_stream(session) if self.stream is None: - return + return None self.get_m3u8(session) self.set_urls() self.set_artist_dir(out_dir) self.set_filename(out_dir) outfile: Optional[Path] = self.set_outfile() if outfile is None: - return + return None if self.download(session, out_dir) is None: - return + return None self.craft_tags() self.set_tags()