🎼 A command-line tool that allows you to download artwork and metadata from Spotify tracks and albums without authentication.
"""
Grabify
~~~~~~~~~~~~~~~~~~~

🎼 A command-line tool that allows you to download artwork and metadata from Spotify 
tracks and albums without authentication.

:copyright: (c) 2023 woidzero
:license: MIT, see LICENSE for more details.
"""

__version__ = "1.1.0"

grabify/__init__.py
woidzero/grabify
"""
Grabify
~~~~~~~~~~~~~~~~~~~

🎼 A command-line tool that allows you to download artwork and metadata from Spotify 
tracks and albums without authentication.

:copyright: (c) 2023 woidzero
:license: MIT, see LICENSE for more details.
"""

import sys

import click

from .cli import grabify


def main() -> click.Group:
    """Grabify entry point"""
    return grabify()


if __name__ == "__main__":
    sys.exit(main())  # type: ignore

grabify/__main__.py
woidzero/grabify
"""
Grabify
~~~~~~~~~~~~~~~~~~~

🎼 A command-line tool that allows you to download artwork and metadata from Spotify 
tracks and albums without authentication.

:copyright: (c) 2023 woidzero
:license: MIT, see LICENSE for more details.
"""

import click
import requests
from bs4 import BeautifulSoup
from rich.console import Console

from . import __version__
from .config import cfg
from .utils import get_data_dict, save, theme

console = Console(theme=theme)


@click.group("grabify")
@click.version_option(__version__, "-v", "--version")
def grabify() -> None:
    """A command-line tool that allows you to download
    artwork and metadata from Spotify tracks and albums.
    """


@grabify.group("config")
def _config() -> None:
    """Manage config settings"""
    pass


@_config.command("set")
@click.argument("key")
@click.argument("value")
def _set(key, value) -> None:
    """Set config value"""
    try:
        if key not in cfg.to_dict():
            return console.print(f"[err]✖[/] No '{key}' found in the config.")

        cfg.set(key, value)

        console.print(f"[ok]✔[/] Config updated! '{key}' is set to '{value}'")
    except Exception as err:
        console.print(f"[err]✖[/] Unknown error: {err}")


@_config.command("view")
def _view() -> None:
    """View config values"""
    console.print(f"Config path: {cfg._filepath}\n")
    console.print(cfg.to_str())


@_config.command("reset")
def _reset() -> None:
    """Restore default settings"""
    cfg.create()
    console.print("[ok]✔[/] Config restored to default settings")


@grabify.command("art")
@click.argument("url")
@click.option("--path", "-p", help="Download path", default=cfg.get("download_path"))
def _art(url, path) -> None:
    """Downloads album/playlist/track artwork"""
    try:
        image_url = ""
        name = ""

        response = requests.get(url, timeout=60)
        soup = BeautifulSoup(response.text, features="lxml")

        for meta in soup.find_all("meta"):
            if meta.get("property") == "og:title":
                name = meta.get("content")
            if meta.get("property") == "og:image":
                image_url = meta.get("content")

        if None in (image_url, name):
            console.print("[err]✖[/] Failed to fetch data")

        saved_path = save(path, name, image_url=image_url)
        console.print(f"[ok]✔[/] Saved to {saved_path}")
    except requests.exceptions.MissingSchema:
        console.print("[err]✖[/] Incorrect URL")
    except requests.exceptions.Timeout:
        console.print("[err]✖[/] Connection timed out, try again later")


@grabify.command("data")
@click.argument("url")
@click.option("--path", "-p", help="Download path", default=cfg.get("download_path"))
def _data(url: str, path: str) -> None:
    """Downloads album/playlist/track metadata"""
    try:
        name = ""
        image_url = ""
        desc = ""
        _type = ""
        raw_data = ""

        response = requests.get(url, timeout=60)
        soup = BeautifulSoup(response.text, features="lxml")

        for meta in soup.find_all("meta"):
            if meta.get("property") == "og:title":
                name = meta.get("content")
            if meta.get("property") == "og:image":
                image_url = meta.get("content")
            if meta.get("property") == "og:description":
                raw_data = meta.get("content")
            if meta.get("property") == "og:type":
                _type = meta.get("content")

        if None in (image_url, name, desc):
            console.print("[err]✖[/] Failed to fetch data")

        data = get_data_dict(raw_data, _type, name, image_url)

        if cfg.get("format_filenames") is True:
            name = name.lower().replace(" ", "_")

        saved_path = save(path, name, json_data=data)
        console.print(f"[ok]✔[/] Saved to {saved_path}")
    except requests.exceptions.MissingSchema:
        console.print("[err]✖[/] Incorrect URL")
    except requests.exceptions.Timeout:
        console.print("[err]✖[/] Connection timed out, try again later")

grabify/cli.py
woidzero/grabify
"""
Grabify
~~~~~~~~~~~~~~~~~~~

🎼 A command-line tool that allows you to download artwork and metadata from Spotify 
tracks and albums without authentication.

:copyright: (c) 2023 woidzero
:license: MIT, see LICENSE for more details.
"""

import json
import os
from pathlib import Path


class Config:
    def __init__(self) -> None:
        self._path = os.getenv('LOCALAPPDATA') + "\\Grabify"
        self._filepath = self._path + "\\settings.json"

        self._download_path = (Path.home() / "Downloads\\Grabify").resolve()
        self._default_config = {
            "download_path": str(self._download_path),
            "format_filenames": True,
        }
        if not os.path.exists(self._path):
            self.create()

    def create(self) -> None:
        os.makedirs(self._path, exist_ok=True)

        with open(self._filepath, "w") as file:
            json.dump(self._default_config, file)

    def set(self, key, value) -> None:
        with open(self._filepath, "r") as file:
            data = json.load(file)

        data[key] = value
        data.update()

        with open(self._filepath, "w") as file:
            json.dump(data, file)

    def get(self, key) -> None:
        with open(self._filepath, "r") as file:
            data = json.load(file)

        return data[key]

    def to_dict(self) -> dict:
        with open(self._filepath, "r") as file:
            return json.load(file)

    def to_str(self) -> str:
        data = self.to_dict()
        res = ""

        for key in data.keys():
            value = data[key]
            res += f"{key}: {value}\n"

        return res


cfg = Config()

grabify/config.py
woidzero/grabify
"""
Grabify
~~~~~~~~~~~~~~~~~~~

🎼 A command-line tool that allows you to download artwork and metadata from Spotify 
tracks and albums without authentication.

:copyright: (c) 2023 woidzero
:license: MIT, see LICENSE for more details.
"""
import json
import os
from pathlib import Path
from typing import Any, Optional, Union

import requests
from rich.theme import Theme

theme = Theme(
    {
        "info": "bold blue",
        "warn": "bold yellow",
        "err": "bold red",
        "ok": "bold green",
    }
)


def uniquify(path: str) -> str:
    """
    Ensure a filename is unique by adding a number to the end if necessary.
    This is useful for generating filenames that are unique across the filesystem.
    """
    base, ext = os.path.splitext(path)
    counter = 1

    while os.path.exists(path):
        path = f"{base}_{counter}{ext}"
        counter += 1

    return path


def save(
    path: str,
    filename: str,
    image_url: Optional[str] = None,
    json_data: Optional[dict] = None,
) -> Union[str, Any]:
    """
    Save file to path. If image_url is provided it will be downloaded
    and saved as jpg file.
    """
    dirc = str(Path(path).resolve()) + os.sep
    os.makedirs(dirc, exist_ok=True)

    ext = ".jpg" if image_url else ".json"
    dist = uniquify(dirc + filename + ext)

    if image_url:
        data = requests.get(image_url, timeout=60).content
        with open(dist, "wb+") as file:
            file.write(data)
    else:
        data = json.dumps(json_data)
        with open(dist, "w+", encoding="utf-8") as file:
            file.write(data)

    return dist


def get_data_dict(raw_data, _type, name, image_url) -> dict:
    """
    Converts data to dict. This is a helper function to make it easier to use in tests
    """
    raw_data = str(raw_data).split(" · ")

    songs = int(raw_data[-1].replace("songs", "").replace(".", ""))

    data = {
        "name": name,
        "image_url": image_url,
        "type": _type,
        "songs": songs,
    }

    if _type != "music.playlist":
        data.update({"year": raw_data[2], "author": raw_data[0]})

    if _type not in ("music.playlist", "music.song"):
        data.update({"songs": songs})

    if _type == "music.song":
        del data["songs"]

    return data

grabify/utils.py
woidzero/grabify