readme, bugfixes better UX
This commit is contained in:
parent
f11b5f014c
commit
312f0ce9d8
9 changed files with 223 additions and 121 deletions
22
README.md
Normal file
22
README.md
Normal 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.
|
30
conf.toml
30
conf.toml
|
@ -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
|
|
|
@ -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]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
git+https://github.com/tamland/python-tidal
|
tidalapi
|
||||||
pycrypto
|
pycrypto
|
||||||
tqdm
|
tqdm
|
||||||
mutagen
|
mutagen
|
||||||
|
|
|
@ -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!")
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue