spots-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. spots_cli/__init__.py +125 -0
  2. spots_cli/app/__init__.py +5 -0
  3. spots_cli/app/domain_resolver.py +157 -0
  4. spots_cli/app/downloader.py +78 -0
  5. spots_cli/app/media_resolver.py +180 -0
  6. spots_cli/app/spotify_playlist_compilation.py +251 -0
  7. spots_cli/app/youtube_user_playlist.py +215 -0
  8. spots_cli/bootstrap/__init__.py +1 -0
  9. spots_cli/bootstrap/container.py +230 -0
  10. spots_cli/cli.py +42 -0
  11. spots_cli/clients/__init__.py +5 -0
  12. spots_cli/clients/deezer.py +80 -0
  13. spots_cli/clients/secrets_manager.py +25 -0
  14. spots_cli/clients/spotify.py +79 -0
  15. spots_cli/clients/youtube.py +66 -0
  16. spots_cli/clients/ytdl.py +72 -0
  17. spots_cli/config/__init__.py +0 -0
  18. spots_cli/core/__init__.py +6 -0
  19. spots_cli/core/history_manager.py +39 -0
  20. spots_cli/core/lyrics_finder.py +58 -0
  21. spots_cli/core/pattern_matcher.py +226 -0
  22. spots_cli/core/video_converter.py +146 -0
  23. spots_cli/core/web_scraper.py +82 -0
  24. spots_cli/core/youtube_extractor.py +162 -0
  25. spots_cli/engine/__init__.py +3 -0
  26. spots_cli/engine/file_storage.py +347 -0
  27. spots_cli/engine/persistence_model.py +3 -0
  28. spots_cli/engine/retry.py +34 -0
  29. spots_cli/integrations/__init__.py +1 -0
  30. spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
  31. spots_cli/models/__init__.py +24 -0
  32. spots_cli/models/artist_metadata.py +23 -0
  33. spots_cli/models/errors.py +69 -0
  34. spots_cli/models/helper_models.py +25 -0
  35. spots_cli/models/media_resource.py +20 -0
  36. spots_cli/models/metadata.py +36 -0
  37. spots_cli/models/metadata_provider.py +52 -0
  38. spots_cli/models/playlist_info.py +27 -0
  39. spots_cli/models/search_provider.py +65 -0
  40. spots_cli/models/sentinel.py +4 -0
  41. spots_cli/models/yt_video_info.py +29 -0
  42. spots_cli/scripts/__init__.py +2 -0
  43. spots_cli/scripts/add_to_history.py +45 -0
  44. spots_cli/scripts/bootstrap.py +92 -0
  45. spots_cli/scripts/duplicate_songs_remover.py +54 -0
  46. spots_cli/services/__init__.py +8 -0
  47. spots_cli/services/deezer_metadata_service.py +108 -0
  48. spots_cli/services/deezer_search_service.py +132 -0
  49. spots_cli/services/provider_search_container.py +67 -0
  50. spots_cli/services/providers_metadata.py +22 -0
  51. spots_cli/services/spotify_metadata_service.py +151 -0
  52. spots_cli/services/spotify_search_service.py +228 -0
  53. spots_cli/services/youtube_metadata_service.py +73 -0
  54. spots_cli/services/youtube_search_service.py +211 -0
  55. spots_cli/setup_env.py +118 -0
  56. spots_cli/utils/__init__.py +4 -0
  57. spots_cli/utils/config_dir.py +17 -0
  58. spots_cli/utils/detect_browser.py +30 -0
  59. spots_cli/utils/fetch.py +73 -0
  60. spots_cli/utils/search_fallbacks.py +21 -0
  61. spots_cli-0.1.0.dist-info/METADATA +146 -0
  62. spots_cli-0.1.0.dist-info/RECORD +65 -0
  63. spots_cli-0.1.0.dist-info/WHEEL +4 -0
  64. spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
  65. spots_cli-0.1.0.dist-info/licenses/LICENSE +675 -0
spots_cli/cli.py ADDED
@@ -0,0 +1,42 @@
1
+ from os.path import exists
2
+ from logging import getLogger
3
+ from typing import Callable
4
+ import typer
5
+
6
+ from spots_cli import setup_env, Spots
7
+ from spots_cli.scripts import remove_duplicate_songs, update_history
8
+
9
+ logger = getLogger(__name__)
10
+
11
+ app = typer.Typer()
12
+
13
+
14
+ @app.command()
15
+ def setup():
16
+ setup_env.main()
17
+
18
+
19
+ @app.command()
20
+ def download(query: str):
21
+ app = Spots()
22
+ app.download(query)
23
+
24
+
25
+ @app.command()
26
+ def migrate_likes():
27
+ app = Spots()
28
+ app.transfer_spotify_likes()
29
+
30
+
31
+ @app.command()
32
+ def add_to_history(path_to_music: str):
33
+ update_history(path_to_music)
34
+
35
+
36
+ @app.command()
37
+ def remove_duplicates(path_to_music: str):
38
+ remove_duplicate_songs(path_to_music)
39
+
40
+
41
+ def main():
42
+ app()
@@ -0,0 +1,5 @@
1
+ from spots_cli.clients.deezer import DeezerClient
2
+ from spots_cli.clients.ytdl import YtDlpClient
3
+ from spots_cli.clients.spotify import SpotifyClient
4
+ from spots_cli.clients.youtube import YouTubeApiClient
5
+ from spots_cli.clients.secrets_manager import SecretsManager
@@ -0,0 +1,80 @@
1
+ from logging import getLogger
2
+ from requests.exceptions import HTTPError
3
+ from tenacity import stop_after_delay
4
+ from typing import Any, TypedDict, cast
5
+
6
+ from spots_cli.engine import retry
7
+ from spots_cli.models import SongNotFound, InvalidURL, ArtistInfo
8
+ from spots_cli.utils import FetchResponseFailure, FetchResponseSuccess, fetch_data
9
+
10
+ logger = getLogger(__name__)
11
+
12
+
13
+ class DeezerResponseBase(TypedDict):
14
+ total: int
15
+ data: list[dict[str, Any]]
16
+
17
+
18
+ class DeezerClient:
19
+
20
+ @retry(stop=stop_after_delay(60))
21
+ def _get_resource_by_url(self, resource_link: str) -> dict[str, Any]:
22
+ base_url = "https://api.deezer.com/"
23
+ if not resource_link.startswith(base_url):
24
+ raise InvalidURL(resource_link)
25
+
26
+ resource_response = fetch_data(resource_link)
27
+
28
+ if isinstance(resource_response, FetchResponseFailure):
29
+ resource_error = cast(FetchResponseFailure, resource_response)
30
+ error_msg = resource_error.error
31
+
32
+ if error_msg == "no data":
33
+ raise SongNotFound(f"Resource not found: {resource_link}")
34
+ else:
35
+ raise Exception(f"Unknown error occured: {error_msg}")
36
+
37
+ resource_success = cast(FetchResponseSuccess, resource_response)
38
+ resource_payload = cast(dict[str, Any], resource_success.data)
39
+ return resource_payload
40
+
41
+ def artist_top_tracks(
42
+ self, artist_id: int
43
+ ) -> tuple[ArtistInfo, list[dict[str, Any]]]:
44
+ base_url = f"https://api.deezer.com/artist/{artist_id}"
45
+ top_tracks_res = self._get_resource_by_url(f"{base_url}/top")
46
+
47
+ artist_info = self._get_resource_by_url(base_url)
48
+
49
+ top_tracks = cast(list[dict[str, Any]], top_tracks_res["data"])
50
+ return (
51
+ ArtistInfo(
52
+ name=artist_info["name"],
53
+ cover=artist_info["picture"],
54
+ id=artist_info["id"],
55
+ ),
56
+ top_tracks,
57
+ )
58
+
59
+ def track(self, track_id: int) -> dict[str, Any]:
60
+ track_url = f"https://api.deezer.com/track/{track_id}"
61
+ return self._get_resource_by_url(track_url)
62
+
63
+ @retry(stop=stop_after_delay(60))
64
+ def search(self, query: str) -> str:
65
+ try:
66
+ logger.debug(f"Searching for {query} on Deezer")
67
+ search_url = f"https://api.deezer.com/search?q={query}&limit=1"
68
+ res = fetch_data(search_url)
69
+ if res.success and res.data is not None:
70
+ res_json = cast(DeezerResponseBase, res.data)
71
+ if res_json["total"] == 0:
72
+ logger.error("No results found")
73
+ raise SongNotFound(query)
74
+
75
+ return str(res_json["data"][0]["id"])
76
+ else:
77
+ raise SongNotFound(query)
78
+ except HTTPError as e:
79
+ logger.error("An error occurred", e)
80
+ raise SongNotFound(query)
@@ -0,0 +1,25 @@
1
+ from os import getenv, environ
2
+ from typing import Literal, overload
3
+ from dotenv import load_dotenv
4
+
5
+ from spots_cli.utils import get_config_path
6
+
7
+ load_dotenv(dotenv_path=get_config_path() / ".env")
8
+
9
+
10
+ class SecretsManager:
11
+ @overload
12
+ @staticmethod
13
+ def read(*, key: str, alt: str) -> str: ...
14
+
15
+ @overload
16
+ @staticmethod
17
+ def read(*, key: str, alt: Literal[None] = None) -> str | None: ...
18
+
19
+ @staticmethod
20
+ def read(*, key, alt=None):
21
+ return getenv(key) or alt
22
+
23
+ @staticmethod
24
+ def write(*, key: str, value: str):
25
+ environ[key] = value
@@ -0,0 +1,79 @@
1
+ from dotenv import load_dotenv
2
+ from logging import getLogger
3
+ from os import getenv
4
+ from spotipy.exceptions import SpotifyException
5
+ from spotipy.oauth2 import SpotifyClientCredentials
6
+ from spotipy import Spotify
7
+ from spotipy.util import prompt_for_user_token
8
+
9
+ load_dotenv()
10
+
11
+ logger = getLogger(__name__)
12
+
13
+ DEFAULT_USER_SCOPE = "user-library-read"
14
+
15
+
16
+ class SpotifyClient:
17
+ """A class to retrieve metadata for a spotify track, album or playlist
18
+
19
+ Attributes:
20
+ track_url (str): The spotify url to be processed
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ """Initializes a spotify.spotify"""
25
+
26
+ auth_manager = SpotifyClientCredentials()
27
+ self.client = Spotify(auth_manager=auth_manager)
28
+
29
+ def get_user(self) -> str | None:
30
+ """Retrieves the current user
31
+
32
+ Returns:
33
+ str: The user's display name
34
+ """
35
+ if not getenv("username"):
36
+ return None
37
+
38
+ user = None
39
+
40
+ try:
41
+ self.signin()
42
+ user = self.client.current_user()
43
+ except SpotifyException as e:
44
+ logger.error(f"An error occured while signin user in::\n\t{e.msg}")
45
+
46
+ if user:
47
+ return user["display_name"]
48
+
49
+ return None
50
+
51
+ def signin(self) -> None:
52
+ """signs into a user's spotify account"""
53
+ # sign user in if username present in env
54
+ username = getenv("username")
55
+ if not username:
56
+ logger.info("Set Spotify `username` in `.env`")
57
+ raise Exception("No `username` declared in environment variables")
58
+
59
+ scope = getenv("scope") or DEFAULT_USER_SCOPE
60
+
61
+ logger.info(f"Signing in to {username} on Spotify with scope: {scope}")
62
+
63
+ try:
64
+ # throws error if user not signed in
65
+ self.client.current_user()
66
+ return
67
+ except SpotifyException as e:
68
+ if e.code == -1:
69
+ token = prompt_for_user_token(username, scope)
70
+ else:
71
+ raise
72
+
73
+ if token:
74
+ logger.info("Signed in")
75
+ self.client = Spotify(auth=token)
76
+
77
+ return
78
+ else:
79
+ raise Exception("Can't get token for", username)
@@ -0,0 +1,66 @@
1
+ import os
2
+ import pickle
3
+ from importlib.resources import files
4
+ from os.path import exists
5
+ from googleapiclient.discovery import build
6
+ from google_auth_oauthlib.flow import InstalledAppFlow
7
+ from google.auth.transport.requests import Request
8
+
9
+ from spots_cli.utils import get_config_path
10
+
11
+ SCOPES = ["https://www.googleapis.com/auth/youtube.force-ssl"]
12
+ config_dir = get_config_path()
13
+ PICKLE_TOKEN = f"{config_dir}/token.pickle"
14
+
15
+
16
+ class YouTubeApiClient:
17
+ """
18
+ Service for interacting with the YouTube API
19
+
20
+ Attributes:
21
+ @service (Unknown): The youtube api service.
22
+
23
+ Methods
24
+ @like_video
25
+
26
+ This service handles:
27
+ - Adding a video to YouTube likes library
28
+ """
29
+
30
+ def __init__(self):
31
+ """
32
+ Initialize the YouTubeApiClient.
33
+
34
+ Sets up the api client.
35
+ """
36
+ self.secrets_file = str(files("spots_cli.config").joinpath("client_secrets.json"))
37
+ if not exists(self.secrets_file):
38
+ raise RuntimeError("Provide path to cookies for YouTube account access.")
39
+
40
+ self.service = self._build_service()
41
+
42
+ def _build_service(self):
43
+ """
44
+ Build service for interacting with YouTube API
45
+
46
+ Returns:
47
+ Unknown: YouTube client.
48
+ """
49
+ creds = None
50
+ if os.path.exists(PICKLE_TOKEN):
51
+ with open(PICKLE_TOKEN, "rb") as f:
52
+ creds = pickle.load(f)
53
+
54
+ if not creds or not creds.valid:
55
+ if creds and creds.expired and creds.refresh_token:
56
+ creds.refresh(Request())
57
+ else:
58
+ flow = InstalledAppFlow.from_client_secrets_file(
59
+ self.secrets_file, SCOPES
60
+ )
61
+ creds = flow.run_local_server(port=0)
62
+
63
+ with open(PICKLE_TOKEN, "wb") as f:
64
+ pickle.dump(creds, f)
65
+
66
+ return build("youtube", "v3", credentials=creds)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+ from typing import TYPE_CHECKING
5
+ from yt_dlp import YoutubeDL
6
+
7
+ from spots_cli.utils import get_config_path, detect_browser
8
+
9
+ if TYPE_CHECKING:
10
+ from yt_dlp import _Params
11
+ from spots_cli.clients import SecretsManager
12
+
13
+
14
+ logger = getLogger(__name__)
15
+
16
+
17
+ class YtDlpClient:
18
+ """
19
+ A service for interacting with the YouTube DL library.
20
+
21
+ Attributes:
22
+ @ydl (YoutubeDL): YouTube DL client.
23
+ @video_to_mp3 (VideoToMp3Service): Service for post-video-downloaded processing.
24
+ @directory_path (str, optional): The directory to save the audio. Defaults to ''.
25
+
26
+ Methods:
27
+ @download_youtube_video
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ extra_options: _Params = {},
34
+ secrets: SecretsManager,
35
+ ):
36
+ """
37
+ Initialize the YtDlpClient.
38
+
39
+ Sets up youtube clients.
40
+ """
41
+ self.default_options: _Params = {
42
+ "js_runtimes": {"node": {}},
43
+ "remote_components": {"ejs:github"},
44
+ "format": "bestaudio/best",
45
+ "outtmpl": "%(title)s.%(ext)s",
46
+ "quiet": True,
47
+ "noplaylist": True,
48
+ "cachedir": str(get_config_path() / "yt_cache"),
49
+ }
50
+ self.client_options: _Params = self.default_options | extra_options
51
+
52
+ browser = detect_browser()
53
+ if browser:
54
+ self.client_options["cookiesfrombrowser"] = browser
55
+ cookies_path = secrets.read(key="YOUTUBE_COOKIES_PATH")
56
+ if cookies_path:
57
+ self.client_options["cookiefile"] = cookies_path
58
+
59
+ self.client = YoutubeDL(self.client_options)
60
+
61
+ @property
62
+ def options(self) -> _Params:
63
+ return self.client_options
64
+
65
+ @options.setter
66
+ def options(self, extra_options: _Params) -> None:
67
+ self.client_options = self.client_options | extra_options
68
+ self.client = YoutubeDL(self.client_options)
69
+
70
+ def reset_options(self) -> None:
71
+ self.client_options = self.default_options
72
+ self.client = YoutubeDL(self.default_options)
File without changes
@@ -0,0 +1,6 @@
1
+ from spots_cli.core.web_scraper import WebScraper
2
+ from spots_cli.core.lyrics_finder import LyricsFinder
3
+ from spots_cli.core.history_manager import HistoryManager
4
+ from spots_cli.core.pattern_matcher import PatternMatcher
5
+ from spots_cli.core.video_converter import VideoConverter
6
+ from spots_cli.core.youtube_extractor import YouTubeExtractor
@@ -0,0 +1,39 @@
1
+ from os import path
2
+
3
+ from spots_cli.utils import get_config_path
4
+
5
+
6
+ class HistoryManager:
7
+ def __init__(self):
8
+ self.__history_file = get_config_path() / ".spots_download_history.txt"
9
+
10
+ def history_file_exists(self):
11
+ if not path.isfile(self.__history_file):
12
+ with open(self.__history_file, "w", newline=""):
13
+ pass
14
+
15
+ def read(self, title: str) -> bool:
16
+ """Checks if a title is in history
17
+
18
+ Args:
19
+ title (str, optional): The title to be checked.
20
+ """
21
+
22
+ title_exists = False
23
+
24
+ with open(self.__history_file, "r") as file:
25
+ history = file.read().split("\n")
26
+ for song in history:
27
+ if title == song:
28
+ title_exists = True
29
+
30
+ return title_exists
31
+
32
+ def write(self, title: str):
33
+ """Adds a downloaded song's title to history
34
+
35
+ Args:
36
+ title (str, optional): The title to be added.
37
+ """
38
+ with open(self.__history_file, "a", newline="") as file:
39
+ file.write(f"{title}\n")
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from logging import getLogger
4
+ from lyricsgenius import Genius
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from spots_cli.clients import SecretsManager
9
+ from spots_cli.core import WebScraper
10
+
11
+
12
+ logger = getLogger(__name__)
13
+
14
+
15
+ class LyricsFinder:
16
+ """
17
+ A service for retrieving lyrics for a song
18
+
19
+ Args:
20
+ scraper (WebScrapeService): The web scraper client.
21
+
22
+ Attributes:
23
+ genius: Genius api client.
24
+ """
25
+
26
+ def __init__(self, *, scraper: WebScraper, secrets_manager: SecretsManager):
27
+ self.secrets_manager = secrets_manager
28
+ self.scraper = scraper
29
+ genius_key = self.secrets_manager.read(key="lyricsgenius_key")
30
+ self.genius = Genius(genius_key) if genius_key else None
31
+
32
+ def get_lyrics(self, *, artist: str, title: str) -> str:
33
+ """Retrieve lyrics using Genius. Falls back to using alternatives provided by the scraper.
34
+
35
+ Args:
36
+ artist (str): The artist.
37
+ title (str): The title.
38
+
39
+ Returns:
40
+ str: The lyrics if found, else an empty string.
41
+ """
42
+ try:
43
+ if self.genius:
44
+ logger.debug("Searching for lyrics on Genius")
45
+ song = self.genius.search_song(title, artist)
46
+
47
+ if not song or "Verse" not in song.lyrics or title not in song.title:
48
+ logger.debug("Genius lyrics not found. Searching AZLyrics")
49
+ lyrics = self.scraper.scrape_azlyrics(artist=artist, title=title)
50
+ else:
51
+ lyrics = song.lyrics
52
+ else:
53
+ logger.debug("Genius API not available. Falling back to AZLyrics")
54
+ lyrics = self.scraper.scrape_azlyrics(artist=artist, title=title)
55
+ except Exception as e:
56
+ logger.error(e)
57
+ lyrics = ""
58
+ return lyrics