2
0
Fork 0

readme, bugfixes better UX

This commit is contained in:
Luca Bilke 2023-07-18 14:25:14 +02:00
parent f11b5f014c
commit 312f0ce9d8
No known key found for this signature in database
GPG key ID: 7B77C51E8C779E75
9 changed files with 223 additions and 121 deletions

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# tidal-scraper
A simple command-line tool to download songs from tidal.
## Installation
```sh
pip install tidal-scraper
```
## Usage
Before using, execute `tidal-scraper -C` to write the configuration file. You will have to configure your user ID, which can be found in the last part of the URL of your tidal profile.
You run the script with one "Object" flag, which is one of `--album`, `--artist`, `--track` or `--playlist`, and one "Source" flag.
The source flag can either be `--favorite` or `--id`, which must be followed by the ID of the object you are trying to download.
You can find the ID in the URL of the album/track/artist e.g. "296153229" in "https://listen.tidal.com/album/296153229"
In addition to this, you can use `--config` to specify what configuration file to use and `--state` to specify a directory to keep state files in.
The state directory will contain a token file called 'auth.json' used to log into your tidal account. Keep this safe and do not share it!
## Use at Your Own Risk!
I will not be liable to you or anyone else for the loss of your tidal account as the result of using this script! While I have extensively tested it without any problems whatsoever, I cannot guarantee that your account will not be banned or otherwise impacted.

View file

@ -1,30 +0,0 @@
# All options postfixed "_dir" are expected to have a trailing slash!
skip_downloaded = true
error_log = "error.log"
# Quality can be one of "master, lossless, high and low"
# Keep in mind that you can only get the quality included in your tidal sub
quality = "lossless"
user_id =
dest_dir = "./downloads/"
# Leave empty for default state location
# Default location is the first valid path in this order:
# XDG_STATE_HOME/tidal-scraper
# XDG_CACHE_HOME/tidal-scraper
# $HOME/.cache/tidal-scraper
state_dir =
# These templates are passed their respective tidalapi objects
# Possible attributes can be found here: https://tidalapi.netlify.app/api.html
album_dir = "{album.artist.name}/{album.name}/"
playlist_dir = "{playlist.name}/"
# Rather than receiving an artist, the track receives both "albumartist" and a "trackartist"
track_name = "{track.track_num}: {track.artist.name} - {track.name}"
# One of 160, 320, 480, 640, 750, 1080
playlist_image_size = 1080
# One of 80, 160, 320, 640, 1280
album_image_size = 1280
debug = false

View file

@ -20,7 +20,7 @@ dependencies = [
"pycrypto", "pycrypto",
"tqdm", "tqdm",
"mutagen", "mutagen",
"tidalapi @ git+https://github.com/tamland/python-tidal@5207a3cff2af437a2d0d67b743c875a67f8d1d08", "tidalapi",
] ]
[tool.hatch.metadata] [tool.hatch.metadata]

View file

@ -1,4 +1,4 @@
git+https://github.com/tamland/python-tidal tidalapi
pycrypto pycrypto
tqdm tqdm
mutagen mutagen

View file

@ -1,34 +1,47 @@
from tidalapi import Album, Track, Artist, Playlist from tidal_scraper.state import State
from tidal_scraper.helper import get_conf, write_conf
from tidal_scraper.download import ( from tidal_scraper.download import (
download_album, download_album,
download_playlist, download_playlist,
download_track, download_track,
download_artist, download_artist,
) )
from tidal_scraper.state import State
from tidal_scraper.helper import get_conf, human_sleep import sys
from tidalapi import Album, Track, Artist, Playlist
from argparse import ArgumentParser, Namespace from argparse import ArgumentParser, Namespace
from pathlib import Path from pathlib import Path
def save_state(
state: State, obj: Playlist | Track | Album | Artist, state_file: str
) -> None:
state.set_dl_state(obj, True)
state.write_dl_state(state_file)
def handle_favorites(state: State, conf: dict, args: Namespace) -> None: def handle_favorites(state: State, conf: dict, args: Namespace) -> None:
match args.obj: match args.obj:
case "album": case "album":
for album in state.favorites.albums(): for album in state.favorites.albums():
if not state.state_downloaded(album):
download_album(album, conf) download_album(album, conf)
human_sleep() save_state(state, album, conf["state_dir"] + "/state.json")
case "track": case "track":
for track in state.favorites.tracks(): for track in state.favorites.tracks():
download_track(track, conf) if not state.state_downloaded(track):
human_sleep() download_track(track, True, conf)
save_state(state, track, conf["state_dir"] + "/state.json")
case "artist": case "artist":
for artist in state.favorites.artists(): for artist in state.favorites.artists():
if not state.state_downloaded(artist):
download_artist(artist, conf) download_artist(artist, conf)
human_sleep() save_state(state, artist, conf["state_dir"] + "/state.json")
case "playlist": case "playlist":
for playlist in state.favorites.playlists(): for playlist in state.favorites.playlists():
if not state.state_downloaded(playlist):
download_playlist(playlist, conf) download_playlist(playlist, conf)
human_sleep() save_state(state, playlist, conf["state_dir"] + "/state.json")
def handle_id(state: State, conf: dict, args: Namespace) -> None: def handle_id(state: State, conf: dict, args: Namespace) -> None:
@ -36,15 +49,19 @@ def handle_id(state: State, conf: dict, args: Namespace) -> None:
case "album": case "album":
album = Album(state.session, args.id) album = Album(state.session, args.id)
download_album(album, conf) download_album(album, conf)
state.set_dl_state(album, True)
case "track": case "track":
track = Track(state.session, args.id) track = Track(state.session, args.id)
download_track(track, conf) download_track(track, False, conf)
state.set_dl_state(track, True)
case "artist": case "artist":
artist = Artist(state.session, args.id) artist = Artist(state.session, args.id)
download_artist(artist, conf) download_artist(artist, conf)
state.set_dl_state(artist, True)
case "playlist": case "playlist":
playlist = Playlist(state.session, args.id) playlist = Playlist(state.session, args.id)
download_playlist(playlist, conf) download_playlist(playlist, conf)
state.set_dl_state(playlist, True)
def run(): def run():
@ -55,6 +72,13 @@ def run():
parser.add_argument( parser.add_argument(
"-s", "--state", help="Directory to keep state in", type=Path, dest="state_dir" "-s", "--state", help="Directory to keep state in", type=Path, dest="state_dir"
) )
parser.add_argument(
"-C",
"--create-conf",
action="store_true",
help="Create config file and exit",
dest="create_conf",
)
obj = parser.add_mutually_exclusive_group(required=True) obj = parser.add_mutually_exclusive_group(required=True)
obj.add_argument( obj.add_argument(
"-a", "-a",
@ -105,13 +129,22 @@ def run():
) )
args = parser.parse_args() args = parser.parse_args()
try:
conf = get_conf(args.state_dir, args.conf_file) conf = get_conf(args.state_dir, args.conf_file)
state = State(conf["user_id"], conf["quality"], conf["state_dir"]) except FileNotFoundError:
state.login(conf["state_dir"] + "auth.json") write_conf(notify=True)
sys.exit()
if args.favorite: state = State(conf)
state.load_dl_state(conf["state_dir"] + "state.json")
state.login()
try:
if args.create_conf:
write_conf(notify=True)
elif args.favorite:
handle_favorites(state, conf, args) handle_favorites(state, conf, args)
elif args.id is not None: elif args.id is not None:
handle_id(state, conf, args) handle_id(state, conf, args)
except KeyboardInterrupt:
state.write_dl_state(conf["state_dir"] + "state.json") print("Bye bye! Come back again!")

View file

@ -1,11 +1,13 @@
import tidal_scraper.metadata as metadata import tidal_scraper.metadata as metadata
from tidal_scraper.helper import extensions, clean_template, log_error, human_sleep from tidal_scraper.helper import extensions, clean_template, log_error
import tidalapi import tidalapi
import os import os
import json import json
import requests import requests
import io import io
import time
import random
from tqdm import tqdm from tqdm import tqdm
from base64 import b64decode from base64 import b64decode
from Crypto.Cipher import AES from Crypto.Cipher import AES
@ -55,21 +57,23 @@ def __download_file(url: str, fp: BinaryIO) -> str:
def download_track( def download_track(
track: tidalapi.Track, track: tidalapi.Track,
sleep: bool,
conf: dict | None = None, conf: dict | None = None,
name_template: str | None = None, name_template: str | None = None,
dest_dir: str | None = None, dest_dir: str | None = None,
skip_dl: bool | None = None, skip_downloaded: bool | None = None,
errorfile: str | None = None, errorfile: str | None = None,
) -> None: ) -> None:
album = track.album album = track.album
assert album assert album
assert album.artist
if conf is None: if conf is None:
assert skip_dl is not None assert skip_downloaded is not None
assert errorfile is not None assert errorfile is not None
assert name_template is not None assert name_template is not None
assert dest_dir is not None assert dest_dir is not None
else: else:
skip_dl = skip_dl or conf["skip_downloaded"] skip_downloaded = skip_downloaded or conf["skip_downloaded"]
errorfile = errorfile or conf["error_log"] errorfile = errorfile or conf["error_log"]
dest_dir = dest_dir or conf["dest_dir"] dest_dir = dest_dir or conf["dest_dir"]
name_template = name_template or conf["track_name"] name_template = name_template or conf["track_name"]
@ -79,7 +83,6 @@ def download_track(
http_failures = 0 http_failures = 0
while http_failures <= 3: while http_failures <= 3:
try: try:
print("running")
stream = track.stream() stream = track.stream()
manifest = json.loads(b64decode(stream.manifest)) manifest = json.loads(b64decode(stream.manifest))
url = manifest["urls"][0] url = manifest["urls"][0]
@ -94,8 +97,11 @@ def download_track(
if ext in url: if ext in url:
dest += ext dest += ext
break break
if os.path.exists(dest) and skip_dl: if os.path.exists(dest) and skip_downloaded:
print("Skipping track") print("Skipping track\n")
if sleep:
t = random.randrange(750, 1500) / 1000
time.sleep(t)
return return
assert track.name and album.name assert track.name and album.name
@ -118,12 +124,17 @@ def download_track(
data = b.getvalue() data = b.getvalue()
f.write(data) f.write(data)
print() print()
if sleep:
t = random.randrange(1000, 5000) / 1000
time.sleep(t)
break break
except requests.HTTPError: except requests.HTTPError:
http_failures += 1 http_failures += 1
t = random.randrange(10000, 20000) / 1000
time.sleep(t)
except KeyboardInterrupt as e: except KeyboardInterrupt as e:
raise e raise e
except: except Exception:
log_error( log_error(
errorfile or "error.log", errorfile or "error.log",
"Failure while downloading {artist} - {track}", "Failure while downloading {artist} - {track}",
@ -179,8 +190,7 @@ def download_album(album: tidalapi.Album, conf: dict) -> None:
download_cover(album, conf) download_cover(album, conf)
tracks = album.tracks() tracks = album.tracks()
for track in tracks: for track in tracks:
download_track(track, conf, dest_dir=dest) download_track(track, True, conf, dest_dir=dest)
human_sleep()
def download_playlist(playlist: tidalapi.Playlist, conf: dict) -> None: def download_playlist(playlist: tidalapi.Playlist, conf: dict) -> None:
@ -192,12 +202,10 @@ def download_playlist(playlist: tidalapi.Playlist, conf: dict) -> None:
download_cover(playlist, conf) download_cover(playlist, conf)
tracks = playlist.tracks() tracks = playlist.tracks()
for track in tracks: for track in tracks:
download_track(track, conf, dest_dir=dest) download_track(track, True, conf, dest_dir=dest)
human_sleep()
def download_artist(artist: tidalapi.Artist, conf: dict) -> None: def download_artist(artist: tidalapi.Artist, conf: dict) -> None:
albums = artist.get_albums() albums = artist.get_albums()
for album in albums: for album in albums:
download_album(album, conf) download_album(album, conf)
human_sleep()

View file

@ -1,32 +1,76 @@
import re import re
import os import os
import time
import random
import tomllib import tomllib
import sys import sys
import traceback import traceback
from pprint import pp
extensions = [".flac", ".mp4", ".m4a", ""] extensions = [".flac", ".mp4", ".m4a", ""]
conf_text = """# All options postfixed "_dir" are expected to have a trailing slash!
skip_downloaded = true
error_log = "error.log"
# Quality can be one of "master, lossless, high and low"
# Keep in mind that you can only get the quality included in your tidal sub
quality = "lossless"
user_id =
dest_dir = "./downloads/"
# Leave empty for default state location
# Default location is the first valid path in this order:
# XDG_STATE_HOME/tidal-scraper/
# XDG_CACHE_HOME/tidal-scraper/
# $HOME/.cache/tidal-scraper/
state_dir =
# These templates are passed their respective tidalapi objects
# Possible attributes can be found here: https://tidalapi.netlify.app/api.html
album_dir = "{album.artist.name}/{album.name}/"
playlist_dir = "{playlist.name}/"
# Track receives "albumartist" and "trackartist", but no "artist"
track_name = "{track.track_num}: {track.artist.name} - {track.name}"
# One of 160, 320, 480, 640, 750, 1080
playlist_image_size = 1080
# One of 80, 160, 320, 640, 1280
album_image_size = 1280
"""
def get_conf(state_dir: str | None = None, conf_file: str | None = None) -> dict: def default_conf_file() -> str:
home = os.getenv("HOME") home = os.getenv("HOME")
assert home assert home
return (
conf_file = (
os.getenv("XDG_CONFIG_HOME") or home + "/.config" os.getenv("XDG_CONFIG_HOME") or home + "/.config"
) + "/tidal-scraper/conf.toml" ) + "/tidal-scraper/conf.toml"
def default_state_file() -> str:
home = os.getenv("HOME")
assert home
return (
os.getenv("XDG_STATE_HOME")
or os.getenv("XDG_CACHE_HOME")
or home + "/.cache"
) + "/tidal-scraper/"
def write_conf(conf_file: str | None = None, notify: bool = False) -> None:
if not conf_file:
conf_file = default_conf_file()
with open(conf_file, "w") as f:
f.write(conf_text)
if notify:
print(f"Config file created at {conf_file}. Make sure to set your user ID!")
def get_conf(state_dir: str | None = None, conf_file: str | None = None) -> dict:
if not conf_file:
conf_file = default_conf_file()
with open(conf_file, "rb") as f: with open(conf_file, "rb") as f:
conf = tomllib.load(f) conf = tomllib.load(f)
if not state_dir: if not state_dir:
state_dir = ( state_dir = default_state_file()
os.getenv("XDG_STATE_HOME")
or os.getenv("XDG_CACHE_HOME")
or home + "/.cache"
)
state_dir += "/tidal-scraper"
conf["state_dir"] = state_dir conf["state_dir"] = state_dir
return conf return conf
@ -39,14 +83,9 @@ def clean_template(path: str, **kwargs) -> str:
return "/".join(cleaned_split) return "/".join(cleaned_split)
def log_error(logfile: str, template: str, **kwargs): def log_error(logfile: str, template: str, **kwargs) -> None:
with open(logfile, "a") as f: with open(logfile, "a") as f:
msg = template.format(**kwargs) msg = template.format(**kwargs)
f.write(msg + "\n") f.write(msg + "\n")
traceback.format_exception(*sys.exc_info()) pp(traceback.format_exception(*sys.exc_info()), stream=f, indent=4)
f.write("\n\n") f.write("\n\n")
def human_sleep() -> None:
t = random.randrange(1000, 5000) / 1000
time.sleep(t)

View file

@ -1,8 +1,6 @@
from mutagen import flac, mp4 from mutagen import flac, mp4
from mutagen.mp4 import MP4Tags from mutagen.mp4 import MP4Tags
from mutagen._vorbis import VCommentDict from mutagen._vorbis import VCommentDict
from typing import BinaryIO from typing import BinaryIO

View file

@ -1,45 +1,47 @@
from tidal_scraper.helper import log_error
import json import json
from datetime import datetime from datetime import datetime
from tidalapi import session, user, playlist, media, album, artist from tidalapi import session, user, playlist, media, album, artist, Quality
from helper import log_error
class State: class State:
def __init__( def __init__(
self, self,
user_id: int,
quality: str,
dl_state_path: str,
conf: dict | None = None, conf: dict | None = None,
state_dir: str | None = None,
user_id: int | None = None,
quality: str | None = None,
errorfile: str | None = None, errorfile: str | None = None,
): ):
if conf is None: if conf is None:
assert user_id is not None
assert quality is not None
assert state_dir is not None
assert errorfile is not None assert errorfile is not None
else: else:
user_id = user_id or conf["user_id"]
quality = quality or conf["quality"]
state_dir = state_dir or conf["state_dir"]
errorfile = errorfile or conf["error_log"] errorfile = errorfile or conf["error_log"]
match quality: match quality:
case "master": case "master":
q = session.Quality.master q = Quality.master
case "lossless": case "lossless":
q = session.Quality.lossless q = Quality.lossless
case "high": case "high":
q = session.Quality.high q = Quality.high
case "low": case "low":
q = session.Quality.low q = Quality.low
case _: case _:
raise Exception("Bad Quality String") raise Exception("Bad Quality String")
config = session.Config(quality=q) api_config = session.Config(quality=q)
self.conf = conf
self.user_id = user_id self.user_id = user_id
self.session = session.Session(config) self.session = session.Session(api_config)
self.favorites = user.Favorites(self.session, user_id) self.favorites = user.Favorites(self.session, user_id)
try: self.errorfile = errorfile
self.load_dl_state(dl_state_path)
except (FileNotFoundError, IndexError, AssertionError):
log_error(
errorfile or "error.log",
f"Could not find state file at {dl_state_path}",
)
self._state = { self._state = {
"albums": {}, "albums": {},
"artists": {}, "artists": {},
@ -49,6 +51,9 @@ class State:
def login(self, auth_file: str | None = None) -> None: def login(self, auth_file: str | None = None) -> None:
s = self.session s = self.session
if auth_file is None:
assert self.conf is not None
auth_file = self.conf["state_dir"] + "auth.json"
try: try:
assert auth_file assert auth_file
with open(auth_file, "r") as f: with open(auth_file, "r") as f:
@ -75,7 +80,7 @@ class State:
"expiry_time": s.expiry_time.timestamp(), "expiry_time": s.expiry_time.timestamp(),
} }
with open(auth_file, "w") as f: with open(auth_file, "w") as f:
json.dump(data, f) json.dump(data, fp=f, indent=4)
assert self.session.check_login() assert self.session.check_login()
@ -94,19 +99,46 @@ class State:
case media.Track: case media.Track:
t = "tracks" t = "tracks"
case _: case _:
raise Exception("Incorrect object type received") raise Exception("Object of incorrect type received")
self._state[t][obj.id] = downloaded self._state[t][obj.id] = downloaded
def state_downloaded(
self, obj: playlist.Playlist | media.Track | album.Album | artist.Artist
) -> bool:
match type(obj):
case album.Album:
t = "albums"
case artist.Artist:
t = "artists"
case playlist.Playlist:
t = "playlists"
case media.Track:
t = "tracks"
case _:
raise Exception("Object of incorrect type received")
return self._state[t].get(str(obj.id), False)
def write_dl_state(self, statefile: str) -> None: def write_dl_state(self, statefile: str) -> None:
with open(statefile, "w") as f: with open(statefile, "w") as f:
json.dump(self._state, f) json.dump(self._state, fp=f, indent=4)
def load_dl_state(self, statefile: str) -> None: def load_dl_state(self, state_file: str) -> None:
with open(statefile, "r") as f: try:
with open(state_file, "r") as f:
self._state = json.load(f) self._state = json.load(f)
assert type(self._state["albums"]) is dict[int, bool] for t in self._state.values():
assert type(self._state["artists"]) is dict[int, bool] for k, v in t.items():
assert type(self._state["playlists"]) is dict[int, bool] assert isinstance(k, (str, type(None)))
assert type(self._state["tracks"]) is dict[int, bool] assert isinstance(v, (bool, type(None)))
except (FileNotFoundError, IndexError, KeyError):
log_error(
self.errorfile or "error.log",
f"Could not find state file at {state_file}",
)
except (json.decoder.JSONDecodeError, AssertionError):
log_error(
self.errorfile or "error.log",
f"Statefile at {state_file} is malformed",
)