mirror of
https://github.com/nathom/streamrip.git
synced 2025-05-13 14:44:49 -04:00
Add repair command #98
Signed-off-by: nathom <nathanthomas707@gmail.com>
This commit is contained in:
parent
ec5afef1b3
commit
715ac496f1
11 changed files with 417 additions and 267 deletions
32
rip/cli.py
32
rip/cli.py
|
@ -1,6 +1,7 @@
|
||||||
"""The streamrip command line interface."""
|
"""The streamrip command line interface."""
|
||||||
import click
|
import click
|
||||||
import logging
|
import logging
|
||||||
|
from streamrip import __version__
|
||||||
|
|
||||||
logging.basicConfig(level="WARNING")
|
logging.basicConfig(level="WARNING")
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
@ -21,10 +22,10 @@ logger = logging.getLogger("streamrip")
|
||||||
metavar="INT",
|
metavar="INT",
|
||||||
help="0: < 320kbps, 1: 320 kbps, 2: 16 bit/44.1 kHz, 3: 24 bit/<=96 kHz, 4: 24 bit/<=192 kHz",
|
help="0: < 320kbps, 1: 320 kbps, 2: 16 bit/44.1 kHz, 3: 24 bit/<=96 kHz, 4: 24 bit/<=192 kHz",
|
||||||
)
|
)
|
||||||
@click.option("-t", "--text", metavar="PATH")
|
@click.option("-t", "--text", metavar="PATH", help="Download urls from a text file.")
|
||||||
@click.option("-nd", "--no-db", is_flag=True)
|
@click.option("-nd", "--no-db", is_flag=True, help="Ignore the database.")
|
||||||
@click.option("--debug", is_flag=True)
|
@click.option("--debug", is_flag=True, help="Show debugging logs.")
|
||||||
@click.version_option(prog_name="streamrip")
|
@click.version_option(prog_name="rip", version=__version__)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs):
|
def cli(ctx, **kwargs):
|
||||||
"""Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music downloader.
|
"""Streamrip: The all-in-one Qobuz, Tidal, SoundCloud, and Deezer music downloader.
|
||||||
|
@ -42,9 +43,8 @@ def cli(ctx, **kwargs):
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from streamrip import __version__
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from streamrip.constants import CONFIG_DIR
|
from .constants import CONFIG_DIR
|
||||||
from .core import MusicDL
|
from .core import MusicDL
|
||||||
|
|
||||||
logging.basicConfig(level="WARNING")
|
logging.basicConfig(level="WARNING")
|
||||||
|
@ -60,7 +60,14 @@ def cli(ctx, **kwargs):
|
||||||
logger.setLevel("DEBUG")
|
logger.setLevel("DEBUG")
|
||||||
logger.debug("Starting debug log")
|
logger.debug("Starting debug log")
|
||||||
|
|
||||||
if ctx.invoked_subcommand not in {None, "lastfm", "search", "discover", "config"}:
|
if ctx.invoked_subcommand not in {
|
||||||
|
None,
|
||||||
|
"lastfm",
|
||||||
|
"search",
|
||||||
|
"discover",
|
||||||
|
"config",
|
||||||
|
"repair",
|
||||||
|
}:
|
||||||
return
|
return
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
@ -284,7 +291,7 @@ def lastfm(ctx, source, url):
|
||||||
def config(ctx, **kwargs):
|
def config(ctx, **kwargs):
|
||||||
"""Manage the streamrip configuration file."""
|
"""Manage the streamrip configuration file."""
|
||||||
from streamrip.clients import TidalClient
|
from streamrip.clients import TidalClient
|
||||||
from streamrip.constants import CONFIG_PATH
|
from .constants import CONFIG_PATH
|
||||||
from hashlib import md5
|
from hashlib import md5
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
import shutil
|
import shutil
|
||||||
|
@ -412,6 +419,15 @@ def convert(ctx, **kwargs):
|
||||||
click.secho(f"File {kwargs['path']} does not exist.", fg="red")
|
click.secho(f"File {kwargs['path']} does not exist.", fg="red")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option(
|
||||||
|
"-n", "--num-items", help="The number of items to atttempt downloads for."
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def repair(ctx, **kwargs):
|
||||||
|
core.repair()
|
||||||
|
|
||||||
|
|
||||||
def none_chosen():
|
def none_chosen():
|
||||||
"""Print message if nothing was chosen."""
|
"""Print message if nothing was chosen."""
|
||||||
click.secho("No items chosen, exiting.", fg="bright_red")
|
click.secho("No items chosen, exiting.", fg="bright_red")
|
||||||
|
|
|
@ -10,7 +10,7 @@ from typing import Any, Dict
|
||||||
import click
|
import click
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from streamrip.constants import CONFIG_DIR, CONFIG_PATH, DOWNLOADS_DIR
|
from .constants import CONFIG_DIR, CONFIG_PATH, DOWNLOADS_DIR
|
||||||
from streamrip.exceptions import InvalidSourceError
|
from streamrip.exceptions import InvalidSourceError
|
||||||
|
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
|
@ -56,7 +56,13 @@ download_videos = false
|
||||||
video_downloads_folder = ""
|
video_downloads_folder = ""
|
||||||
|
|
||||||
# This stores a list of item IDs so that repeats are not downloaded.
|
# This stores a list of item IDs so that repeats are not downloaded.
|
||||||
[database]
|
[database.downloads]
|
||||||
|
enabled = true
|
||||||
|
path = ""
|
||||||
|
|
||||||
|
# If a download fails, the item ID is stored here. Then, `rip repair` can be
|
||||||
|
# called to retry the downloads
|
||||||
|
[database.failed_downloads]
|
||||||
enabled = true
|
enabled = true
|
||||||
path = ""
|
path = ""
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,6 @@ URL_REGEX = re.compile(
|
||||||
r"(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"
|
r"(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"
|
||||||
)
|
)
|
||||||
SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+")
|
SOUNDCLOUD_URL_REGEX = re.compile(r"https://soundcloud.com/[-\w:/]+")
|
||||||
SOUNDCLOUD_CLIENT_ID = re.compile("a3e059563d7fd3372b49b37f00a00bcf")
|
|
||||||
LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+")
|
LASTFM_URL_REGEX = re.compile(r"https://www.last.fm/user/\w+/playlists/\w+")
|
||||||
QOBUZ_INTERPRETER_URL_REGEX = re.compile(
|
QOBUZ_INTERPRETER_URL_REGEX = re.compile(
|
||||||
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+"
|
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+"
|
||||||
|
|
74
rip/core.py
74
rip/core.py
|
@ -48,6 +48,7 @@ from .constants import (
|
||||||
from . import db
|
from . import db
|
||||||
from streamrip.exceptions import (
|
from streamrip.exceptions import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
PartialFailure,
|
||||||
MissingCredentials,
|
MissingCredentials,
|
||||||
NonStreamable,
|
NonStreamable,
|
||||||
NoResultsFound,
|
NoResultsFound,
|
||||||
|
@ -74,6 +75,8 @@ MEDIA_CLASS: Dict[str, Media] = {
|
||||||
"label": Label,
|
"label": Label,
|
||||||
"video": Video,
|
"video": Video,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DB_PATH_MAP = {"downloads": DB_PATH, "failed_downloads": FAILED_DB_PATH}
|
||||||
# ---------------------------------------------- #
|
# ---------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@ -102,18 +105,28 @@ class MusicDL(list):
|
||||||
"soundcloud": SoundCloudClient(),
|
"soundcloud": SoundCloudClient(),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.db: db.Database
|
def get_db(db_type: str) -> db.Database:
|
||||||
db_settings = self.config.session["database"]
|
db_settings = self.config.session["database"]
|
||||||
if db_settings["enabled"]:
|
db_class = db.CLASS_MAP[db_type]
|
||||||
path = db_settings["path"]
|
database = db_class(None, dummy=True)
|
||||||
if path:
|
|
||||||
self.db = db.Downloads(path)
|
default_db_path = DB_PATH_MAP[db_type]
|
||||||
else:
|
if db_settings[db_type]["enabled"]:
|
||||||
self.db = db.Downloads(DB_PATH)
|
path = db_settings[db_type]["path"]
|
||||||
self.config.file["database"]["path"] = DB_PATH
|
|
||||||
self.config.save()
|
if path:
|
||||||
else:
|
database = db_class(path)
|
||||||
self.db = db.Downloads(None, empty=True)
|
else:
|
||||||
|
database = db_class(default_db_path)
|
||||||
|
|
||||||
|
assert config is not None
|
||||||
|
config.file["database"][db_type]["path"] = default_db_path
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
return database
|
||||||
|
|
||||||
|
self.db = get_db("downloads")
|
||||||
|
self.failed_db = get_db("failed_downloads")
|
||||||
|
|
||||||
def handle_urls(self, urls):
|
def handle_urls(self, urls):
|
||||||
"""Download a url.
|
"""Download a url.
|
||||||
|
@ -217,6 +230,23 @@ class MusicDL(list):
|
||||||
"max_artwork_height": int(artwork["max_height"]),
|
"max_artwork_height": int(artwork["max_height"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def repair(self, max_items=float("inf")):
|
||||||
|
print(list(self.failed_db))
|
||||||
|
if self.failed_db.is_dummy:
|
||||||
|
click.secho(
|
||||||
|
"Failed downloads database must be enabled to repair!", fg="red"
|
||||||
|
)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
for counter, (source, media_type, item_id) in enumerate(self.failed_db):
|
||||||
|
# print(f"handling item {source = } {media_type = } {item_id = }")
|
||||||
|
if counter >= max_items:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.handle_item(source, media_type, item_id)
|
||||||
|
|
||||||
|
self.download()
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
"""Download all the items in self."""
|
"""Download all the items in self."""
|
||||||
try:
|
try:
|
||||||
|
@ -256,10 +286,24 @@ class MusicDL(list):
|
||||||
try:
|
try:
|
||||||
item.load_meta(**arguments)
|
item.load_meta(**arguments)
|
||||||
except NonStreamable:
|
except NonStreamable:
|
||||||
|
self.failed_db.add((item.client.source, item.type, item.id))
|
||||||
click.secho(f"{item!s} is not available, skipping.", fg="red")
|
click.secho(f"{item!s} is not available, skipping.", fg="red")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if item.download(**arguments) and hasattr(item, "id"):
|
try:
|
||||||
|
item.download(**arguments)
|
||||||
|
except NonStreamable as e:
|
||||||
|
print("caught in core")
|
||||||
|
e.print(item)
|
||||||
|
self.failed_db.add((item.client.source, item.type, item.id))
|
||||||
|
continue
|
||||||
|
except PartialFailure as e:
|
||||||
|
for failed_item in e.failed_items:
|
||||||
|
print(f"adding {failed_item} to database")
|
||||||
|
self.failed_db.add(failed_item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hasattr(item, "id"):
|
||||||
self.db.add([item.id])
|
self.db.add([item.id])
|
||||||
|
|
||||||
if isinstance(item, Track):
|
if isinstance(item, Track):
|
||||||
|
@ -355,7 +399,7 @@ class MusicDL(list):
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed.extend(URL_REGEX.findall(url)) # Qobuz, Tidal, Dezer
|
parsed.extend(URL_REGEX.findall(url)) # Qobuz, Tidal, Dezer
|
||||||
soundcloud_urls = URL_REGEX.findall(url)
|
soundcloud_urls = SOUNDCLOUD_URL_REGEX.findall(url)
|
||||||
soundcloud_items = [self.clients["soundcloud"].get(u) for u in soundcloud_urls]
|
soundcloud_items = [self.clients["soundcloud"].get(u) for u in soundcloud_urls]
|
||||||
|
|
||||||
parsed.extend(
|
parsed.extend(
|
||||||
|
@ -558,7 +602,7 @@ class MusicDL(list):
|
||||||
ret = fmt.format(**{k: media.get(k, default="Unknown") for k in fields})
|
ret = fmt.format(**{k: media.get(k, default="Unknown") for k in fields})
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def interactive_search( # noqa
|
def interactive_search(
|
||||||
self, query: str, source: str = "qobuz", media_type: str = "album"
|
self, query: str, source: str = "qobuz", media_type: str = "album"
|
||||||
):
|
):
|
||||||
"""Show an interactive menu that contains search results.
|
"""Show an interactive menu that contains search results.
|
||||||
|
|
108
rip/db.py
108
rip/db.py
|
@ -3,96 +3,118 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Union, List
|
from typing import List
|
||||||
import abc
|
|
||||||
|
|
||||||
logger = logging.getLogger("streamrip")
|
logger = logging.getLogger("streamrip")
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
# list of table column names
|
structure: dict
|
||||||
structure: list
|
|
||||||
# name of table
|
# name of table
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def __init__(self, path, empty=False):
|
def __init__(self, path, dummy=False):
|
||||||
assert self.structure != []
|
assert self.structure != []
|
||||||
assert self.name
|
assert self.name
|
||||||
|
|
||||||
if empty:
|
if dummy or path is None:
|
||||||
self.path = None
|
self.path = None
|
||||||
|
self.is_dummy = True
|
||||||
return
|
return
|
||||||
|
self.is_dummy = False
|
||||||
|
|
||||||
self.path = path
|
self.path = path
|
||||||
if not os.path.exists(self.path):
|
if not os.path.exists(self.path):
|
||||||
self.create()
|
self.create()
|
||||||
|
|
||||||
def create(self):
|
def create(self):
|
||||||
if self.path is None:
|
if self.is_dummy:
|
||||||
return
|
return
|
||||||
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
with sqlite3.connect(self.path) as conn:
|
||||||
try:
|
params = ", ".join(
|
||||||
params = ", ".join(
|
f"{key} {' '.join(map(str.upper, props))}"
|
||||||
f"{key} TEXT UNIQUE NOT NULL" for key in self.structure
|
for key, props in self.structure.items()
|
||||||
)
|
)
|
||||||
command = f"CREATE TABLE {self.name} ({params});"
|
command = f"CREATE TABLE {self.name} ({params})"
|
||||||
|
|
||||||
logger.debug(f"executing {command}")
|
|
||||||
|
|
||||||
conn.execute(command)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def keys(self):
|
|
||||||
return self.structure
|
|
||||||
|
|
||||||
def contains(self, **items):
|
|
||||||
allowed_keys = set(self.structure)
|
|
||||||
assert all(
|
|
||||||
key in allowed_keys for key in items.keys()
|
|
||||||
), f"Invalid key. Valid keys: {self.structure}"
|
|
||||||
|
|
||||||
items = {k: str(v) for k, v in items.items()}
|
|
||||||
|
|
||||||
if self.path is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
conditions = " AND ".join(f"{key}=?" for key in items.keys())
|
|
||||||
command = f"SELECT {self.structure[0]} FROM {self.name} WHERE {conditions}"
|
|
||||||
|
|
||||||
logger.debug(f"executing {command}")
|
logger.debug(f"executing {command}")
|
||||||
|
|
||||||
return conn.execute(command, tuple(items.values())).fetchone() is not None
|
conn.execute(command)
|
||||||
|
|
||||||
|
def keys(self):
|
||||||
|
return self.structure.keys()
|
||||||
|
|
||||||
|
def contains(self, **items):
|
||||||
|
if self.is_dummy:
|
||||||
|
return False
|
||||||
|
|
||||||
|
allowed_keys = set(self.structure.keys())
|
||||||
|
assert all(
|
||||||
|
key in allowed_keys for key in items.keys()
|
||||||
|
), f"Invalid key. Valid keys: {allowed_keys}"
|
||||||
|
|
||||||
|
items = {k: str(v) for k, v in items.items()}
|
||||||
|
|
||||||
|
with sqlite3.connect(self.path) as conn:
|
||||||
|
conditions = " AND ".join(f"{key}=?" for key in items.keys())
|
||||||
|
command = f"SELECT EXISTS(SELECT 1 FROM {self.name} WHERE {conditions})"
|
||||||
|
|
||||||
|
logger.debug(f"executing {command}")
|
||||||
|
|
||||||
|
result = conn.execute(command, tuple(items.values()))
|
||||||
|
return result
|
||||||
|
|
||||||
def __contains__(self, keys: dict) -> bool:
|
def __contains__(self, keys: dict) -> bool:
|
||||||
return self.contains(**keys)
|
return self.contains(**keys)
|
||||||
|
|
||||||
def add(self, items: List[str]):
|
def add(self, items: List[str]):
|
||||||
assert len(items) == len(self.structure)
|
if self.is_dummy:
|
||||||
if self.path is None:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
params = ", ".join(self.structure)
|
assert len(items) == len(self.structure)
|
||||||
|
|
||||||
|
params = ", ".join(self.structure.keys())
|
||||||
question_marks = ", ".join("?" for _ in items)
|
question_marks = ", ".join("?" for _ in items)
|
||||||
command = f"INSERT INTO {self.name} ({params}) VALUES ({question_marks})"
|
command = f"INSERT INTO {self.name} ({params}) VALUES ({question_marks})"
|
||||||
|
|
||||||
logger.debug(f"executing {command}")
|
logger.debug(f"executing {command}")
|
||||||
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
with sqlite3.connect(self.path) as conn:
|
||||||
conn.execute(command, tuple(items))
|
try:
|
||||||
|
conn.execute(command, tuple(items))
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
# tried to insert an item that was already there
|
||||||
|
logger.debug(e)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
|
if self.is_dummy:
|
||||||
|
return ()
|
||||||
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
with sqlite3.connect(self.path) as conn:
|
||||||
return conn.execute(f"SELECT * FROM {self.name}")
|
return conn.execute(f"SELECT * FROM {self.name}")
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
try:
|
||||||
|
os.remove(self.path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Downloads(Database):
|
class Downloads(Database):
|
||||||
structure = ["id"]
|
|
||||||
name = "downloads"
|
name = "downloads"
|
||||||
|
structure = {
|
||||||
|
"id": ["unique", "text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FailedDownloads(Database):
|
class FailedDownloads(Database):
|
||||||
structure = ["source", "type", "id"]
|
|
||||||
name = "failed_downloads"
|
name = "failed_downloads"
|
||||||
|
structure = {
|
||||||
|
"source": ["text"],
|
||||||
|
"media_type": ["text"],
|
||||||
|
"id": ["text", "unique"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CLASS_MAP = {db.name: db for db in (Downloads, FailedDownloads)}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
"""Constants that are kept in one place."""
|
"""Constants that are kept in one place."""
|
||||||
|
|
||||||
import mutagen.id3 as id3
|
import mutagen.id3 as id3
|
||||||
|
import re
|
||||||
|
|
||||||
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
|
AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
|
||||||
|
|
||||||
TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
TIDAL_COVER_URL = "https://resources.tidal.com/images/{uuid}/{width}x{height}.jpg"
|
||||||
|
SOUNDCLOUD_CLIENT_ID = re.compile("a3e059563d7fd3372b49b37f00a00bcf")
|
||||||
|
|
||||||
|
|
||||||
QUALITY_DESC = {
|
QUALITY_DESC = {
|
||||||
|
@ -132,20 +134,6 @@ FOLDER_FORMAT = (
|
||||||
TRACK_FORMAT = "{tracknumber}. {artist} - {title}"
|
TRACK_FORMAT = "{tracknumber}. {artist} - {title}"
|
||||||
|
|
||||||
|
|
||||||
# ------------------ Regexes ------------------- #
|
|
||||||
URL_REGEX = (
|
|
||||||
r"https?://(?:www|open|play|listen)?\.?(qobuz|tidal|deezer)\.com(?:(?:/"
|
|
||||||
r"(album|artist|track|playlist|video|label))|(?:\/[-\w]+?))+\/([-\w]+)"
|
|
||||||
)
|
|
||||||
SOUNDCLOUD_URL_REGEX = r"https://soundcloud.com/[-\w:/]+"
|
|
||||||
SOUNDCLOUD_CLIENT_ID = "a3e059563d7fd3372b49b37f00a00bcf"
|
|
||||||
LASTFM_URL_REGEX = r"https://www.last.fm/user/\w+/playlists/\w+"
|
|
||||||
QOBUZ_INTERPRETER_URL_REGEX = (
|
|
||||||
r"https?://www\.qobuz\.com/\w\w-\w\w/interpreter/[-\w]+/[-\w]+"
|
|
||||||
)
|
|
||||||
DEEZER_DYNAMIC_LINK_REGEX = r"https://deezer\.page\.link/\w+"
|
|
||||||
YOUTUBE_URL_REGEX = r"https://www\.youtube\.com/watch\?v=[-\w]+"
|
|
||||||
|
|
||||||
TIDAL_MAX_Q = 7
|
TIDAL_MAX_Q = 7
|
||||||
|
|
||||||
TIDAL_Q_MAP = {
|
TIDAL_Q_MAP = {
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
from typing import List
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationError(Exception):
|
class AuthenticationError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -23,7 +27,16 @@ class InvalidQuality(Exception):
|
||||||
|
|
||||||
|
|
||||||
class NonStreamable(Exception):
|
class NonStreamable(Exception):
|
||||||
pass
|
def __init__(self, message=None):
|
||||||
|
self.message = message
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
def print(self, item):
|
||||||
|
if self.message:
|
||||||
|
click.secho(f"Unable to stream {item!s}. Message: ", nl=False, fg="yellow")
|
||||||
|
click.secho(self.message, fg="red")
|
||||||
|
else:
|
||||||
|
click.secho(f"Unable to stream {item!s}.", fg="yellow")
|
||||||
|
|
||||||
|
|
||||||
class InvalidContainerError(Exception):
|
class InvalidContainerError(Exception):
|
||||||
|
@ -52,3 +65,13 @@ class ConversionError(Exception):
|
||||||
|
|
||||||
class NoResultsFound(Exception):
|
class NoResultsFound(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ItemExists(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PartialFailure(Exception):
|
||||||
|
def __init__(self, failed_items: List):
|
||||||
|
self.failed_items = failed_items
|
||||||
|
super().__init__()
|
||||||
|
|
|
@ -8,11 +8,12 @@ as a single track.
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import abc
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
from typing import Any, Optional, Union, Iterable, Generator, Dict
|
from typing import Any, Optional, Union, Iterable, Generator, Dict, Tuple, List
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import tqdm
|
import tqdm
|
||||||
|
@ -26,6 +27,8 @@ from .clients import Client
|
||||||
from .constants import FLAC_MAX_BLOCKSIZE, FOLDER_FORMAT, TRACK_FORMAT, ALBUM_KEYS
|
from .constants import FLAC_MAX_BLOCKSIZE, FOLDER_FORMAT, TRACK_FORMAT, ALBUM_KEYS
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
InvalidQuality,
|
InvalidQuality,
|
||||||
|
PartialFailure,
|
||||||
|
ItemExists,
|
||||||
InvalidSourceError,
|
InvalidSourceError,
|
||||||
NonStreamable,
|
NonStreamable,
|
||||||
TooLargeCoverArt,
|
TooLargeCoverArt,
|
||||||
|
@ -35,7 +38,6 @@ from .utils import (
|
||||||
clean_format,
|
clean_format,
|
||||||
downsize_image,
|
downsize_image,
|
||||||
get_cover_urls,
|
get_cover_urls,
|
||||||
decho,
|
|
||||||
decrypt_mqa_file,
|
decrypt_mqa_file,
|
||||||
get_container,
|
get_container,
|
||||||
ext,
|
ext,
|
||||||
|
@ -53,7 +55,38 @@ TYPE_REGEXES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Track:
|
class Media(abc.ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
def download(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def load_meta(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def tag(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def type(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def convert(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def __repr__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def __str__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Track(Media):
|
||||||
"""Represents a downloadable track.
|
"""Represents a downloadable track.
|
||||||
|
|
||||||
Loading metadata as a single track:
|
Loading metadata as a single track:
|
||||||
|
@ -171,15 +204,15 @@ class Track:
|
||||||
self.downloaded = True
|
self.downloaded = True
|
||||||
self.tagged = True
|
self.tagged = True
|
||||||
self.path = self.final_path
|
self.path = self.final_path
|
||||||
decho(f"Track already exists: {self.final_path}", fg="magenta")
|
raise ItemExists(self.final_path)
|
||||||
return False
|
|
||||||
|
if hasattr(self, "cover_url"):
|
||||||
|
self.download_cover(
|
||||||
|
width=kwargs.get("max_artwork_width", 999999),
|
||||||
|
height=kwargs.get("max_artwork_height", 999999),
|
||||||
|
) # only downloads for playlists and singles
|
||||||
|
|
||||||
self.download_cover(
|
|
||||||
width=kwargs.get("max_artwork_width", 999999),
|
|
||||||
height=kwargs.get("max_artwork_height", 999999),
|
|
||||||
) # only downloads for playlists and singles
|
|
||||||
self.path = os.path.join(gettempdir(), f"{hash(self.id)}_{self.quality}.tmp")
|
self.path = os.path.join(gettempdir(), f"{hash(self.id)}_{self.quality}.tmp")
|
||||||
return True
|
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
self,
|
self,
|
||||||
|
@ -187,7 +220,7 @@ class Track:
|
||||||
parent_folder: str = "StreamripDownloads",
|
parent_folder: str = "StreamripDownloads",
|
||||||
progress_bar: bool = True,
|
progress_bar: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> bool:
|
):
|
||||||
"""Download the track.
|
"""Download the track.
|
||||||
|
|
||||||
:param quality: (0, 1, 2, 3, 4)
|
:param quality: (0, 1, 2, 3, 4)
|
||||||
|
@ -197,13 +230,12 @@ class Track:
|
||||||
:param progress_bar: turn on/off progress bar
|
:param progress_bar: turn on/off progress bar
|
||||||
:type progress_bar: bool
|
:type progress_bar: bool
|
||||||
"""
|
"""
|
||||||
if not self._prepare_download(
|
self._prepare_download(
|
||||||
quality=quality,
|
quality=quality,
|
||||||
parent_folder=parent_folder,
|
parent_folder=parent_folder,
|
||||||
progress_bar=progress_bar,
|
progress_bar=progress_bar,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
if self.client.source == "soundcloud":
|
if self.client.source == "soundcloud":
|
||||||
# soundcloud client needs whole dict to get file url
|
# soundcloud client needs whole dict to get file url
|
||||||
|
@ -214,14 +246,14 @@ class Track:
|
||||||
try:
|
try:
|
||||||
dl_info = self.client.get_file_url(url_id, self.quality)
|
dl_info = self.client.get_file_url(url_id, self.quality)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.secho(f"Unable to download track. {e}", fg="red")
|
# click.secho(f"Unable to download track. {e}", fg="red")
|
||||||
return False
|
raise NonStreamable(e)
|
||||||
|
|
||||||
if self.client.source == "qobuz":
|
if self.client.source == "qobuz":
|
||||||
assert isinstance(dl_info, dict) # for typing
|
assert isinstance(dl_info, dict) # for typing
|
||||||
if not self.__validate_qobuz_dl_info(dl_info):
|
if not self.__validate_qobuz_dl_info(dl_info):
|
||||||
click.secho("Track is not available for download", fg="red")
|
# click.secho("Track is not available for download", fg="red")
|
||||||
return False
|
raise NonStreamable("Track is not available for download")
|
||||||
|
|
||||||
self.sampling_rate = dl_info.get("sampling_rate")
|
self.sampling_rate = dl_info.get("sampling_rate")
|
||||||
self.bit_depth = dl_info.get("bit_depth")
|
self.bit_depth = dl_info.get("bit_depth")
|
||||||
|
@ -230,19 +262,12 @@ class Track:
|
||||||
if self.client.source in ("qobuz", "tidal", "deezer"):
|
if self.client.source in ("qobuz", "tidal", "deezer"):
|
||||||
assert isinstance(dl_info, dict)
|
assert isinstance(dl_info, dict)
|
||||||
logger.debug("Downloadable URL found: %s", dl_info.get("url"))
|
logger.debug("Downloadable URL found: %s", dl_info.get("url"))
|
||||||
try:
|
tqdm_download(
|
||||||
tqdm_download(
|
dl_info["url"], self.path, desc=self._progress_desc
|
||||||
dl_info["url"], self.path, desc=self._progress_desc
|
) # downloads file
|
||||||
) # downloads file
|
|
||||||
except NonStreamable:
|
|
||||||
click.secho(
|
|
||||||
f"Track {self!s} is not available for download, skipping.",
|
|
||||||
fg="red",
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
elif self.client.source == "soundcloud":
|
elif self.client.source == "soundcloud":
|
||||||
assert isinstance(dl_info, dict)
|
assert isinstance(dl_info, dict) # for typing
|
||||||
self._soundcloud_download(dl_info)
|
self._soundcloud_download(dl_info)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -254,6 +279,7 @@ class Track:
|
||||||
and dl_info.get("enc_key", False)
|
and dl_info.get("enc_key", False)
|
||||||
):
|
):
|
||||||
out_path = f"{self.path}_dec"
|
out_path = f"{self.path}_dec"
|
||||||
|
logger.debug("Decrypting MQA file")
|
||||||
decrypt_mqa_file(self.path, out_path, dl_info["enc_key"])
|
decrypt_mqa_file(self.path, out_path, dl_info["enc_key"])
|
||||||
self.path = out_path
|
self.path = out_path
|
||||||
|
|
||||||
|
@ -267,8 +293,6 @@ class Track:
|
||||||
if not kwargs.get("keep_cover", True) and hasattr(self, "cover_path"):
|
if not kwargs.get("keep_cover", True) and hasattr(self, "cover_path"):
|
||||||
os.remove(self.cover_path)
|
os.remove(self.cover_path)
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __validate_qobuz_dl_info(self, info: dict) -> bool:
|
def __validate_qobuz_dl_info(self, info: dict) -> bool:
|
||||||
"""Check if the download info dict returned by Qobuz is downloadable.
|
"""Check if the download info dict returned by Qobuz is downloadable.
|
||||||
|
|
||||||
|
@ -335,6 +359,10 @@ class Track:
|
||||||
self.final_path = self.final_path.replace(".mp3", ".flac")
|
self.final_path = self.final_path.replace(".mp3", ".flac")
|
||||||
self.quality = 2
|
self.quality = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return "track"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _progress_desc(self) -> str:
|
def _progress_desc(self) -> str:
|
||||||
"""Get the description that is used on the progress bar.
|
"""Get the description that is used on the progress bar.
|
||||||
|
@ -345,9 +373,6 @@ class Track:
|
||||||
|
|
||||||
def download_cover(self, width=999999, height=999999):
|
def download_cover(self, width=999999, height=999999):
|
||||||
"""Download the cover art, if cover_url is given."""
|
"""Download the cover art, if cover_url is given."""
|
||||||
if not hasattr(self, "cover_url"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.cover_path = os.path.join(gettempdir(), f"cover{hash(self.cover_url)}.jpg")
|
self.cover_path = os.path.join(gettempdir(), f"cover{hash(self.cover_url)}.jpg")
|
||||||
logger.debug(f"Downloading cover from {self.cover_url}")
|
logger.debug(f"Downloading cover from {self.cover_url}")
|
||||||
# click.secho(f"\nDownloading cover art for {self!s}", fg="blue")
|
# click.secho(f"\nDownloading cover art for {self!s}", fg="blue")
|
||||||
|
@ -361,6 +386,7 @@ class Track:
|
||||||
downsize_image(self.cover_path, width, height)
|
downsize_image(self.cover_path, width, height)
|
||||||
else:
|
else:
|
||||||
logger.debug("Cover already exists, skipping download")
|
logger.debug("Cover already exists, skipping download")
|
||||||
|
raise ItemExists(self.cover_path)
|
||||||
|
|
||||||
def format_final_path(self) -> str:
|
def format_final_path(self) -> str:
|
||||||
"""Return the final filepath of the downloaded file.
|
"""Return the final filepath of the downloaded file.
|
||||||
|
@ -430,11 +456,12 @@ class Track:
|
||||||
cover_url=cover_url,
|
cover_url=cover_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
def tag( # noqa
|
def tag(
|
||||||
self,
|
self,
|
||||||
album_meta: dict = None,
|
album_meta: dict = None,
|
||||||
cover: Union[Picture, APIC, MP4Cover] = None,
|
cover: Union[Picture, APIC, MP4Cover] = None,
|
||||||
embed_cover: bool = True,
|
embed_cover: bool = True,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Tag the track using the stored metadata.
|
"""Tag the track using the stored metadata.
|
||||||
|
|
||||||
|
@ -659,7 +686,7 @@ class Track:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Video:
|
class Video(Media):
|
||||||
"""Only for Tidal."""
|
"""Only for Tidal."""
|
||||||
|
|
||||||
def __init__(self, client: Client, id: str, **kwargs):
|
def __init__(self, client: Client, id: str, **kwargs):
|
||||||
|
@ -709,8 +736,6 @@ class Video:
|
||||||
p = subprocess.Popen(command)
|
p = subprocess.Popen(command)
|
||||||
p.wait() # remove this?
|
p.wait() # remove this?
|
||||||
|
|
||||||
return False # so that it is not tagged
|
|
||||||
|
|
||||||
def tag(self, *args, **kwargs):
|
def tag(self, *args, **kwargs):
|
||||||
"""Return False.
|
"""Return False.
|
||||||
|
|
||||||
|
@ -738,6 +763,9 @@ class Video:
|
||||||
tracknumber=track["trackNumber"],
|
tracknumber=track["trackNumber"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def convert(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path(self) -> str:
|
def path(self) -> str:
|
||||||
"""Get path to download the mp4 file.
|
"""Get path to download the mp4 file.
|
||||||
|
@ -753,6 +781,10 @@ class Video:
|
||||||
|
|
||||||
return os.path.join(self.parent_folder, f"{fname}.mp4")
|
return os.path.join(self.parent_folder, f"{fname}.mp4")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return "video"
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return the title.
|
"""Return the title.
|
||||||
|
|
||||||
|
@ -771,6 +803,101 @@ class Video:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeVideo(Media):
|
||||||
|
"""Dummy class implemented for consistency with the Media API."""
|
||||||
|
|
||||||
|
class DummyClient:
|
||||||
|
"""Used because YouTube downloads use youtube-dl, not a client."""
|
||||||
|
|
||||||
|
source = "youtube"
|
||||||
|
|
||||||
|
def __init__(self, url: str):
|
||||||
|
"""Create a YoutubeVideo object.
|
||||||
|
|
||||||
|
:param url: URL to the youtube video.
|
||||||
|
:type url: str
|
||||||
|
"""
|
||||||
|
self.url = url
|
||||||
|
self.client = self.DummyClient()
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
parent_folder: str = "StreamripDownloads",
|
||||||
|
download_youtube_videos: bool = False,
|
||||||
|
youtube_video_downloads_folder: str = "StreamripDownloads",
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Download the video using 'youtube-dl'.
|
||||||
|
|
||||||
|
:param parent_folder:
|
||||||
|
:type parent_folder: str
|
||||||
|
:param download_youtube_videos: True if the video should be downloaded.
|
||||||
|
:type download_youtube_videos: bool
|
||||||
|
:param youtube_video_downloads_folder: Folder to put videos if
|
||||||
|
downloaded.
|
||||||
|
:type youtube_video_downloads_folder: str
|
||||||
|
:param kwargs:
|
||||||
|
"""
|
||||||
|
click.secho(f"Downloading url {self.url}", fg="blue")
|
||||||
|
filename_formatter = "%(track_number)s.%(track)s.%(container)s"
|
||||||
|
filename = os.path.join(parent_folder, filename_formatter)
|
||||||
|
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"youtube-dl",
|
||||||
|
"-x", # audio only
|
||||||
|
"-q", # quiet mode
|
||||||
|
"--add-metadata",
|
||||||
|
"--audio-format",
|
||||||
|
"mp3",
|
||||||
|
"--embed-thumbnail",
|
||||||
|
"-o",
|
||||||
|
filename,
|
||||||
|
self.url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if download_youtube_videos:
|
||||||
|
click.secho("Downloading video stream", fg="blue")
|
||||||
|
pv = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"youtube-dl",
|
||||||
|
"-q",
|
||||||
|
"-o",
|
||||||
|
os.path.join(
|
||||||
|
youtube_video_downloads_folder,
|
||||||
|
"%(title)s.%(container)s",
|
||||||
|
),
|
||||||
|
self.url,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
pv.wait()
|
||||||
|
p.wait()
|
||||||
|
|
||||||
|
def load_meta(self, *args, **kwargs):
|
||||||
|
"""Return None.
|
||||||
|
|
||||||
|
Dummy method.
|
||||||
|
|
||||||
|
:param args:
|
||||||
|
:param kwargs:
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tag(self, *args, **kwargs):
|
||||||
|
"""Return None.
|
||||||
|
|
||||||
|
Dummy method.
|
||||||
|
|
||||||
|
:param args:
|
||||||
|
:param kwargs:
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Booklet:
|
class Booklet:
|
||||||
"""Only for Qobuz."""
|
"""Only for Qobuz."""
|
||||||
|
|
||||||
|
@ -800,6 +927,9 @@ class Booklet:
|
||||||
filepath = os.path.join(parent_folder, f"{self.description}.pdf")
|
filepath = os.path.join(parent_folder, f"{self.description}.pdf")
|
||||||
tqdm_download(self.url, filepath)
|
tqdm_download(self.url, filepath)
|
||||||
|
|
||||||
|
def type(self) -> str:
|
||||||
|
return "booklet"
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -833,12 +963,26 @@ class Tracklist(list):
|
||||||
else:
|
else:
|
||||||
target = self._download_item
|
target = self._download_item
|
||||||
|
|
||||||
|
# TODO: make this function return the items that have not been downloaded
|
||||||
|
failed_downloads: List[Tuple[str, str, str]] = []
|
||||||
if kwargs.get("concurrent_downloads", True):
|
if kwargs.get("concurrent_downloads", True):
|
||||||
# Tidal errors out with unlimited concurrency
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(15) as executor:
|
with concurrent.futures.ThreadPoolExecutor(15) as executor:
|
||||||
futures = [executor.submit(target, item, **kwargs) for item in self]
|
future_map = {
|
||||||
|
executor.submit(target, item, **kwargs): item for item in self
|
||||||
|
}
|
||||||
|
# futures = [executor.submit(target, item, **kwargs) for item in self]
|
||||||
try:
|
try:
|
||||||
concurrent.futures.wait(futures)
|
concurrent.futures.wait(future_map.keys())
|
||||||
|
for future in future_map.keys():
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except NonStreamable:
|
||||||
|
print("caught in media conc")
|
||||||
|
item = future_map[future]
|
||||||
|
failed_downloads.append(
|
||||||
|
(item.client.source, item.type, item.id)
|
||||||
|
)
|
||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
executor.shutdown()
|
executor.shutdown()
|
||||||
tqdm.write("Aborted! May take some time to shutdown.")
|
tqdm.write("Aborted! May take some time to shutdown.")
|
||||||
|
@ -850,20 +994,29 @@ class Tracklist(list):
|
||||||
# soundcloud only gets metadata after `target` is called
|
# soundcloud only gets metadata after `target` is called
|
||||||
# message will be printed in `target`
|
# message will be printed in `target`
|
||||||
click.secho(f'\nDownloading "{item!s}"', fg="blue")
|
click.secho(f'\nDownloading "{item!s}"', fg="blue")
|
||||||
target(item, **kwargs)
|
try:
|
||||||
|
target(item, **kwargs)
|
||||||
|
except ItemExists:
|
||||||
|
click.secho(f"{item!s} exists. Skipping.", fg="yellow")
|
||||||
|
except NonStreamable as e:
|
||||||
|
e.print(item)
|
||||||
|
failed_downloads.append((item.client.source, item.type, item.id))
|
||||||
|
|
||||||
self.downloaded = True
|
self.downloaded = True
|
||||||
|
|
||||||
def _download_and_convert_item(self, item, **kwargs):
|
if failed_downloads:
|
||||||
|
raise PartialFailure(failed_downloads)
|
||||||
|
|
||||||
|
def _download_and_convert_item(self, item: Media, **kwargs):
|
||||||
"""Download and convert an item.
|
"""Download and convert an item.
|
||||||
|
|
||||||
:param item:
|
:param item:
|
||||||
:param kwargs: should contain a `conversion` dict.
|
:param kwargs: should contain a `conversion` dict.
|
||||||
"""
|
"""
|
||||||
if self._download_item(item, **kwargs):
|
self._download_item(item, **kwargs)
|
||||||
item.convert(**kwargs["conversion"])
|
item.convert(**kwargs["conversion"])
|
||||||
|
|
||||||
def _download_item(item, *args: Any, **kwargs: Any) -> bool:
|
def _download_item(self, item: Media, **kwargs: Any):
|
||||||
"""Abstract method.
|
"""Abstract method.
|
||||||
|
|
||||||
:param item:
|
:param item:
|
||||||
|
@ -1017,6 +1170,10 @@ class Tracklist(list):
|
||||||
|
|
||||||
return album
|
return album
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return self.__class__.__name__.lower()
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
"""Get an item if key is int, otherwise get an attr.
|
"""Get an item if key is int, otherwise get an attr.
|
||||||
|
|
||||||
|
@ -1044,101 +1201,6 @@ class Tracklist(list):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class YoutubeVideo:
|
|
||||||
"""Dummy class implemented for consistency with the Media API."""
|
|
||||||
|
|
||||||
class DummyClient:
|
|
||||||
"""Used because YouTube downloads use youtube-dl, not a client."""
|
|
||||||
|
|
||||||
source = "youtube"
|
|
||||||
|
|
||||||
def __init__(self, url: str):
|
|
||||||
"""Create a YoutubeVideo object.
|
|
||||||
|
|
||||||
:param url: URL to the youtube video.
|
|
||||||
:type url: str
|
|
||||||
"""
|
|
||||||
self.url = url
|
|
||||||
self.client = self.DummyClient()
|
|
||||||
|
|
||||||
def download(
|
|
||||||
self,
|
|
||||||
parent_folder: str = "StreamripDownloads",
|
|
||||||
download_youtube_videos: bool = False,
|
|
||||||
youtube_video_downloads_folder: str = "StreamripDownloads",
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""Download the video using 'youtube-dl'.
|
|
||||||
|
|
||||||
:param parent_folder:
|
|
||||||
:type parent_folder: str
|
|
||||||
:param download_youtube_videos: True if the video should be downloaded.
|
|
||||||
:type download_youtube_videos: bool
|
|
||||||
:param youtube_video_downloads_folder: Folder to put videos if
|
|
||||||
downloaded.
|
|
||||||
:type youtube_video_downloads_folder: str
|
|
||||||
:param kwargs:
|
|
||||||
"""
|
|
||||||
click.secho(f"Downloading url {self.url}", fg="blue")
|
|
||||||
filename_formatter = "%(track_number)s.%(track)s.%(container)s"
|
|
||||||
filename = os.path.join(parent_folder, filename_formatter)
|
|
||||||
|
|
||||||
p = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"youtube-dl",
|
|
||||||
"-x", # audio only
|
|
||||||
"-q", # quiet mode
|
|
||||||
"--add-metadata",
|
|
||||||
"--audio-format",
|
|
||||||
"mp3",
|
|
||||||
"--embed-thumbnail",
|
|
||||||
"-o",
|
|
||||||
filename,
|
|
||||||
self.url,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if download_youtube_videos:
|
|
||||||
click.secho("Downloading video stream", fg="blue")
|
|
||||||
pv = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"youtube-dl",
|
|
||||||
"-q",
|
|
||||||
"-o",
|
|
||||||
os.path.join(
|
|
||||||
youtube_video_downloads_folder,
|
|
||||||
"%(title)s.%(container)s",
|
|
||||||
),
|
|
||||||
self.url,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
pv.wait()
|
|
||||||
p.wait()
|
|
||||||
|
|
||||||
def load_meta(self, *args, **kwargs):
|
|
||||||
"""Return None.
|
|
||||||
|
|
||||||
Dummy method.
|
|
||||||
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def tag(self, *args, **kwargs):
|
|
||||||
"""Return None.
|
|
||||||
|
|
||||||
Dummy method.
|
|
||||||
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Album(Tracklist):
|
class Album(Tracklist):
|
||||||
"""Represents a downloadable album.
|
"""Represents a downloadable album.
|
||||||
|
|
||||||
|
@ -1278,12 +1340,7 @@ class Album(Tracklist):
|
||||||
for item in self.booklets:
|
for item in self.booklets:
|
||||||
Booklet(item).download(parent_folder=self.folder)
|
Booklet(item).download(parent_folder=self.folder)
|
||||||
|
|
||||||
def _download_item( # type: ignore
|
def _download_item(self, item: Media, **kwargs: Any):
|
||||||
self,
|
|
||||||
track: Union[Track, Video],
|
|
||||||
quality: int = 3,
|
|
||||||
**kwargs,
|
|
||||||
) -> bool:
|
|
||||||
"""Download an item.
|
"""Download an item.
|
||||||
|
|
||||||
:param track: The item.
|
:param track: The item.
|
||||||
|
@ -1294,25 +1351,24 @@ class Album(Tracklist):
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
logger.debug("Downloading track to %s", self.folder)
|
logger.debug("Downloading track to %s", self.folder)
|
||||||
if self.disctotal > 1 and isinstance(track, Track):
|
if self.disctotal > 1 and isinstance(item, Track):
|
||||||
disc_folder = os.path.join(self.folder, f"Disc {track.meta.discnumber}")
|
disc_folder = os.path.join(self.folder, f"Disc {item.meta.discnumber}")
|
||||||
kwargs["parent_folder"] = disc_folder
|
kwargs["parent_folder"] = disc_folder
|
||||||
else:
|
else:
|
||||||
kwargs["parent_folder"] = self.folder
|
kwargs["parent_folder"] = self.folder
|
||||||
|
|
||||||
if not track.download(quality=min(self.quality, quality), **kwargs):
|
quality = kwargs.get("quality", 3)
|
||||||
return False
|
kwargs.pop("quality")
|
||||||
|
item.download(quality=min(self.quality, quality), **kwargs)
|
||||||
|
|
||||||
logger.debug("tagging tracks")
|
logger.debug("tagging tracks")
|
||||||
# deezer tracks come tagged
|
# deezer tracks come tagged
|
||||||
if kwargs.get("tag_tracks", True) and self.client.source != "deezer":
|
if kwargs.get("tag_tracks", True) and self.client.source != "deezer":
|
||||||
track.tag(
|
item.tag(
|
||||||
cover=self.cover_obj,
|
cover=self.cover_obj,
|
||||||
embed_cover=kwargs.get("embed_cover", True),
|
embed_cover=kwargs.get("embed_cover", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_get_resp(resp: dict, client: Client) -> TrackMetadata:
|
def _parse_get_resp(resp: dict, client: Client) -> TrackMetadata:
|
||||||
"""Parse information from a client.get(query, 'album') call.
|
"""Parse information from a client.get(query, 'album') call.
|
||||||
|
@ -1573,26 +1629,28 @@ class Playlist(Tracklist):
|
||||||
self.__indices = iter(range(1, len(self) + 1))
|
self.__indices = iter(range(1, len(self) + 1))
|
||||||
self.download_message()
|
self.download_message()
|
||||||
|
|
||||||
def _download_item(self, item: Track, **kwargs) -> bool: # type: ignore
|
def _download_item(self, item: Media, **kwargs):
|
||||||
|
assert isinstance(item, Track)
|
||||||
|
|
||||||
kwargs["parent_folder"] = self.folder
|
kwargs["parent_folder"] = self.folder
|
||||||
if self.client.source == "soundcloud":
|
if self.client.source == "soundcloud":
|
||||||
item.load_meta()
|
item.load_meta()
|
||||||
click.secho(f"Downloading {item!s}", fg="blue")
|
click.secho(f"Downloading {item!s}", fg="blue")
|
||||||
|
|
||||||
if playlist_to_album := kwargs.get("set_playlist_to_album", False):
|
if playlist_to_album := kwargs.get("set_playlist_to_album", False):
|
||||||
item["album"] = self.name
|
item.meta.album = self.name
|
||||||
item["albumartist"] = self.creator
|
item.meta.albumartist = self.creator
|
||||||
|
|
||||||
if kwargs.get("new_tracknumbers", True):
|
if kwargs.get("new_tracknumbers", True):
|
||||||
item["tracknumber"] = next(self.__indices)
|
item.meta.tracknumber = next(self.__indices)
|
||||||
item["discnumber"] = 1
|
item.meta.discnumber = 1
|
||||||
|
|
||||||
self.downloaded = item.download(**kwargs)
|
item.download(**kwargs)
|
||||||
|
|
||||||
if self.downloaded and self.client.source != "deezer":
|
if self.client.source != "deezer":
|
||||||
item.tag(embed_cover=kwargs.get("embed_cover", True))
|
item.tag(embed_cover=kwargs.get("embed_cover", True))
|
||||||
|
|
||||||
if self.downloaded and playlist_to_album and self.client.source == "deezer":
|
if playlist_to_album and self.client.source == "deezer":
|
||||||
# Because Deezer tracks come pre-tagged, the `set_playlist_to_album`
|
# Because Deezer tracks come pre-tagged, the `set_playlist_to_album`
|
||||||
# option is never set. Here, we manually do this
|
# option is never set. Here, we manually do this
|
||||||
from mutagen.flac import FLAC
|
from mutagen.flac import FLAC
|
||||||
|
@ -1603,8 +1661,6 @@ class Playlist(Tracklist):
|
||||||
audio["TRACKNUMBER"] = f"{item['tracknumber']:02}"
|
audio["TRACKNUMBER"] = f"{item['tracknumber']:02}"
|
||||||
audio.save()
|
audio.save()
|
||||||
|
|
||||||
return self.downloaded
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_get_resp(item: dict, client: Client) -> dict:
|
def _parse_get_resp(item: dict, client: Client) -> dict:
|
||||||
"""Parse information from a search result returned by a client.search call.
|
"""Parse information from a search result returned by a client.search call.
|
||||||
|
@ -1769,13 +1825,7 @@ class Artist(Tracklist):
|
||||||
self.download_message()
|
self.download_message()
|
||||||
return final
|
return final
|
||||||
|
|
||||||
def _download_item( # type: ignore
|
def _download_item(self, item: Media, **kwargs):
|
||||||
self,
|
|
||||||
item,
|
|
||||||
parent_folder: str = "StreamripDownloads",
|
|
||||||
quality: int = 3,
|
|
||||||
**kwargs,
|
|
||||||
) -> bool:
|
|
||||||
"""Download an item.
|
"""Download an item.
|
||||||
|
|
||||||
:param item:
|
:param item:
|
||||||
|
@ -1786,19 +1836,14 @@ class Artist(Tracklist):
|
||||||
:param kwargs:
|
:param kwargs:
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
try:
|
item.load_meta()
|
||||||
item.load_meta()
|
|
||||||
except NonStreamable:
|
|
||||||
logger.info("Skipping album, not available to stream.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
kwargs.pop("parent_folder")
|
||||||
# always an Album
|
# always an Album
|
||||||
status = item.download(
|
item.download(
|
||||||
parent_folder=self.folder,
|
parent_folder=self.folder,
|
||||||
quality=quality,
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
return status
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
|
|
|
@ -148,7 +148,7 @@ def tqdm_download(url: str, filepath: str, params: dict = None, desc: str = None
|
||||||
total = int(r.headers.get("content-length", 0))
|
total = int(r.headers.get("content-length", 0))
|
||||||
logger.debug(f"File size = {total}")
|
logger.debug(f"File size = {total}")
|
||||||
if total < 1000 and not url.endswith("jpg") and not url.endswith("png"):
|
if total < 1000 and not url.endswith("jpg") and not url.endswith("png"):
|
||||||
raise NonStreamable(url)
|
raise NonStreamable("Resource not found.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filepath, "wb") as file, tqdm(
|
with open(filepath, "wb") as file, tqdm(
|
||||||
|
@ -322,9 +322,6 @@ def decho(message, fg=None):
|
||||||
logger.debug(message)
|
logger.debug(message)
|
||||||
|
|
||||||
|
|
||||||
interpreter_artist_regex = re.compile(r"getSimilarArtist\(\s*'(\w+)'")
|
|
||||||
|
|
||||||
|
|
||||||
def get_container(quality: int, source: str) -> str:
|
def get_container(quality: int, source: str) -> str:
|
||||||
"""Get the file container given the quality.
|
"""Get the file container given the quality.
|
||||||
|
|
||||||
|
|
10
test.toml
Normal file
10
test.toml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
[database]
|
||||||
|
bruh = "something"
|
||||||
|
|
||||||
|
[database.downloads]
|
||||||
|
enabled = true
|
||||||
|
path = "asdf"
|
||||||
|
|
||||||
|
[database.failed]
|
||||||
|
enabled = false
|
||||||
|
path = "asrdfg"
|
Loading…
Add table
Add a link
Reference in a new issue