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.
- spots_cli/__init__.py +125 -0
- spots_cli/app/__init__.py +5 -0
- spots_cli/app/domain_resolver.py +157 -0
- spots_cli/app/downloader.py +78 -0
- spots_cli/app/media_resolver.py +180 -0
- spots_cli/app/spotify_playlist_compilation.py +251 -0
- spots_cli/app/youtube_user_playlist.py +215 -0
- spots_cli/bootstrap/__init__.py +1 -0
- spots_cli/bootstrap/container.py +230 -0
- spots_cli/cli.py +42 -0
- spots_cli/clients/__init__.py +5 -0
- spots_cli/clients/deezer.py +80 -0
- spots_cli/clients/secrets_manager.py +25 -0
- spots_cli/clients/spotify.py +79 -0
- spots_cli/clients/youtube.py +66 -0
- spots_cli/clients/ytdl.py +72 -0
- spots_cli/config/__init__.py +0 -0
- spots_cli/core/__init__.py +6 -0
- spots_cli/core/history_manager.py +39 -0
- spots_cli/core/lyrics_finder.py +58 -0
- spots_cli/core/pattern_matcher.py +226 -0
- spots_cli/core/video_converter.py +146 -0
- spots_cli/core/web_scraper.py +82 -0
- spots_cli/core/youtube_extractor.py +162 -0
- spots_cli/engine/__init__.py +3 -0
- spots_cli/engine/file_storage.py +347 -0
- spots_cli/engine/persistence_model.py +3 -0
- spots_cli/engine/retry.py +34 -0
- spots_cli/integrations/__init__.py +1 -0
- spots_cli/integrations/spotify_user_playlist_modify.py +47 -0
- spots_cli/models/__init__.py +24 -0
- spots_cli/models/artist_metadata.py +23 -0
- spots_cli/models/errors.py +69 -0
- spots_cli/models/helper_models.py +25 -0
- spots_cli/models/media_resource.py +20 -0
- spots_cli/models/metadata.py +36 -0
- spots_cli/models/metadata_provider.py +52 -0
- spots_cli/models/playlist_info.py +27 -0
- spots_cli/models/search_provider.py +65 -0
- spots_cli/models/sentinel.py +4 -0
- spots_cli/models/yt_video_info.py +29 -0
- spots_cli/scripts/__init__.py +2 -0
- spots_cli/scripts/add_to_history.py +45 -0
- spots_cli/scripts/bootstrap.py +92 -0
- spots_cli/scripts/duplicate_songs_remover.py +54 -0
- spots_cli/services/__init__.py +8 -0
- spots_cli/services/deezer_metadata_service.py +108 -0
- spots_cli/services/deezer_search_service.py +132 -0
- spots_cli/services/provider_search_container.py +67 -0
- spots_cli/services/providers_metadata.py +22 -0
- spots_cli/services/spotify_metadata_service.py +151 -0
- spots_cli/services/spotify_search_service.py +228 -0
- spots_cli/services/youtube_metadata_service.py +73 -0
- spots_cli/services/youtube_search_service.py +211 -0
- spots_cli/setup_env.py +118 -0
- spots_cli/utils/__init__.py +4 -0
- spots_cli/utils/config_dir.py +17 -0
- spots_cli/utils/detect_browser.py +30 -0
- spots_cli/utils/fetch.py +73 -0
- spots_cli/utils/search_fallbacks.py +21 -0
- spots_cli-0.1.0.dist-info/METADATA +146 -0
- spots_cli-0.1.0.dist-info/RECORD +65 -0
- spots_cli-0.1.0.dist-info/WHEEL +4 -0
- spots_cli-0.1.0.dist-info/entry_points.txt +2 -0
- spots_cli-0.1.0.dist-info/licenses/LICENSE +675 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import Metadata, YTVideoInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PlaylistInfo:
|
|
12
|
+
"""
|
|
13
|
+
A playlist metadata object.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
cover (str): The playlist cover image.
|
|
17
|
+
name (str): The playlist's name.
|
|
18
|
+
artist (str, Optional): The artist of the playlist (if album). Defaults to None.
|
|
19
|
+
provider_metadata (list[Metadata]): A list of provider metadata for the playlist.
|
|
20
|
+
youtube_metadata (list[YTVideoInfo]): A list youtube metadata for the playlist.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
cover: str
|
|
25
|
+
provider_metadata: list[Metadata]
|
|
26
|
+
youtube_metadata: list[YTVideoInfo]
|
|
27
|
+
artist: str | None = None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from spots_cli.bootstrap.container import Clients, Core
|
|
9
|
+
from spots_cli.models import Metadata, PlaylistInfo, MetadataProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ArtistInfo:
|
|
14
|
+
name: str
|
|
15
|
+
cover: str
|
|
16
|
+
id: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SearchProvider(ABC):
|
|
20
|
+
"""Responsible for searching for query on a Media Provider"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
metadata: MetadataProvider,
|
|
27
|
+
clients: Clients,
|
|
28
|
+
core: Core,
|
|
29
|
+
fallback_providers: list[SearchProvider] = [],
|
|
30
|
+
):
|
|
31
|
+
self.fallback_providers = fallback_providers
|
|
32
|
+
self.clients = clients
|
|
33
|
+
self.metadata = metadata
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def search_artist(self, artist_query: str) -> ArtistInfo:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def search_artist_top_tracks(self, *, artist_id: str) -> PlaylistInfo:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def search_playlist(self, playlist_url: str) -> PlaylistInfo:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def search_album(self, album_url: str) -> PlaylistInfo:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def search_track(self, query: str) -> Metadata:
|
|
53
|
+
"""Searches for a title on media provider
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
query (str): the title to be searched for
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
SongNotFound: the `query` is not found.
|
|
60
|
+
TypeError: if query format not: `Artist - Title`
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Metadata: The metadata of the title.
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.models import ArtistAndTitle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class YTVideoInfo:
|
|
12
|
+
id: str
|
|
13
|
+
title: str
|
|
14
|
+
uploader: str
|
|
15
|
+
audio_ext: str
|
|
16
|
+
filesize: int
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def video_link(self):
|
|
20
|
+
return f"https://youtube.com.watch?v={self.id}"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def full_title(self):
|
|
24
|
+
return f"{self.uploader} - {self.title}"
|
|
25
|
+
|
|
26
|
+
@full_title.setter
|
|
27
|
+
def full_title(self, artist_and_title: ArtistAndTitle):
|
|
28
|
+
self.uploader = artist_and_title.artist
|
|
29
|
+
self.title = artist_and_title.title
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Recursively adds user's downloaded songs to spots history file"""
|
|
2
|
+
|
|
3
|
+
from mutagen.id3 import ID3
|
|
4
|
+
from mutagen.mp3 import MP3, HeaderNotFoundError
|
|
5
|
+
from os import listdir, path, getcwd
|
|
6
|
+
|
|
7
|
+
from spots_cli.utils import get_config_path
|
|
8
|
+
|
|
9
|
+
history_file = get_config_path() / ".spots_download_history.txt"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def update_history(file_path):
|
|
13
|
+
"""Update history file with song information"""
|
|
14
|
+
try:
|
|
15
|
+
song = MP3(file_path, ID3)
|
|
16
|
+
artist = song.get("TPE1", "")
|
|
17
|
+
artist = f"{artist} - " if artist else ""
|
|
18
|
+
title = song.get("TIT2", "")
|
|
19
|
+
song_name = f"{artist}{title}"
|
|
20
|
+
if title:
|
|
21
|
+
with open(history_file, "a", newline="") as history:
|
|
22
|
+
history.write(f"{song_name}\n")
|
|
23
|
+
except (HeaderNotFoundError, KeyError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def process_directory(directory):
|
|
28
|
+
"""Process all MP3 files in a directory"""
|
|
29
|
+
for item in listdir(directory):
|
|
30
|
+
item_path = path.join(directory, item)
|
|
31
|
+
if path.isdir(item_path):
|
|
32
|
+
process_directory(item_path) # Recursively process subdirectories
|
|
33
|
+
elif item_path.lower().endswith(".mp3"):
|
|
34
|
+
update_history(item_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(folder):
|
|
38
|
+
if path.exists(folder):
|
|
39
|
+
print("Updating Spots downloads history...")
|
|
40
|
+
|
|
41
|
+
process_directory(folder)
|
|
42
|
+
|
|
43
|
+
print("Spots downloads history updated!")
|
|
44
|
+
else:
|
|
45
|
+
print("Folder not found.")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
ENV_NAME = "venv"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run(cmd, env=None):
|
|
11
|
+
"""Run a shell command and stream output."""
|
|
12
|
+
print(f"\n>> {' '.join(cmd)}")
|
|
13
|
+
subprocess.check_call(cmd, env=env)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_venv_paths(env_name):
|
|
17
|
+
"""Return platform-specific paths."""
|
|
18
|
+
if os.name == "nt":
|
|
19
|
+
python_path = Path(env_name) / "Scripts" / "python.exe"
|
|
20
|
+
pip_path = Path(env_name) / "Scripts" / "pip.exe"
|
|
21
|
+
else:
|
|
22
|
+
python_path = Path(env_name) / "bin" / "python"
|
|
23
|
+
pip_path = Path(env_name) / "bin" / "pip"
|
|
24
|
+
|
|
25
|
+
return python_path, pip_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_venv(env_name, recreate=False):
|
|
29
|
+
"""Create or recreate virtual environment."""
|
|
30
|
+
if Path(env_name).exists():
|
|
31
|
+
if recreate:
|
|
32
|
+
print(f"Removing existing venv: {env_name}")
|
|
33
|
+
shutil.rmtree(env_name)
|
|
34
|
+
else:
|
|
35
|
+
print(f"Venv already exists: {env_name}")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
print(f"Creating virtual environment: {env_name}")
|
|
39
|
+
run([sys.executable, "-m", "venv", env_name])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def install_dependencies(pip_path):
|
|
43
|
+
"""Install dependencies."""
|
|
44
|
+
# Upgrade pip first
|
|
45
|
+
run([str(pip_path), "install", "--upgrade", "pip", "setuptools", "wheel"])
|
|
46
|
+
|
|
47
|
+
# Install from requirements.txt if present
|
|
48
|
+
if Path("requirements.txt").exists():
|
|
49
|
+
print("Installing from requirements.txt...")
|
|
50
|
+
run([str(pip_path), "install", "-r", "requirements.txt"])
|
|
51
|
+
else:
|
|
52
|
+
print("No requirements.txt found, skipping.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def install_current_package(pip_path):
|
|
56
|
+
"""Install current project (useful for PyPI-style projects)."""
|
|
57
|
+
if Path("pyproject.toml").exists() or Path("setup.py").exists():
|
|
58
|
+
print("Installing current package...")
|
|
59
|
+
run([str(pip_path), "install", "-e", "."])
|
|
60
|
+
else:
|
|
61
|
+
print("No project file found (pyproject.toml/setup.py), skipping.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
recreate = "--recreate" in sys.argv
|
|
66
|
+
|
|
67
|
+
print("🚀 Python environment bootstrap")
|
|
68
|
+
|
|
69
|
+
create_venv(ENV_NAME, recreate=recreate)
|
|
70
|
+
|
|
71
|
+
python_path, pip_path = get_venv_paths(ENV_NAME)
|
|
72
|
+
|
|
73
|
+
if not python_path.exists():
|
|
74
|
+
print("❌ Failed to locate venv Python executable")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
install_dependencies(pip_path)
|
|
78
|
+
install_current_package(pip_path)
|
|
79
|
+
|
|
80
|
+
print("\n✅ Setup complete!")
|
|
81
|
+
|
|
82
|
+
if os.name == "nt":
|
|
83
|
+
activate_cmd = f"{ENV_NAME}\\Scripts\\activate"
|
|
84
|
+
else:
|
|
85
|
+
activate_cmd = f"source {ENV_NAME}/bin/activate"
|
|
86
|
+
|
|
87
|
+
print(f"\n👉 To activate the environment:\n{activate_cmd}")
|
|
88
|
+
print(f"\n👉 Or run directly:\n{python_path} your_script.py")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Removes all duplicate mp3 files"""
|
|
2
|
+
|
|
3
|
+
from os import listdir, path, getcwd, remove
|
|
4
|
+
from sys import argv
|
|
5
|
+
from mutagen.id3 import ID3
|
|
6
|
+
from mutagen.mp3 import MP3, HeaderNotFoundError
|
|
7
|
+
|
|
8
|
+
original_songs: dict[str, str] = {}
|
|
9
|
+
|
|
10
|
+
i = 0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def process_song(file_path: str):
|
|
14
|
+
"""Removes a song if it is a duplicate"""
|
|
15
|
+
try:
|
|
16
|
+
song = MP3(file_path, ID3)
|
|
17
|
+
artist = song.get("TPE1", "")
|
|
18
|
+
artist = f"{artist} - " if artist else ""
|
|
19
|
+
title = song.get("TIT2", "")
|
|
20
|
+
song_name = f"{artist}{title}"
|
|
21
|
+
|
|
22
|
+
original = original_songs.get(song_name)
|
|
23
|
+
if original:
|
|
24
|
+
remove(file_path)
|
|
25
|
+
else:
|
|
26
|
+
original_songs[song_name] = file_path
|
|
27
|
+
except (HeaderNotFoundError, KeyError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def process_directory(directory: str):
|
|
32
|
+
"""Process all MP3 files in a directory"""
|
|
33
|
+
contents = listdir(directory)
|
|
34
|
+
contents.sort(reverse=True)
|
|
35
|
+
|
|
36
|
+
for item in contents:
|
|
37
|
+
print(f"[{globals()['i']}] Scanning: {item}")
|
|
38
|
+
globals()["i"] += 1
|
|
39
|
+
item_path = path.join(directory, item)
|
|
40
|
+
if path.isdir(item_path):
|
|
41
|
+
process_directory(item_path) # Recursively process subdirectories
|
|
42
|
+
elif item_path.lower().endswith(".mp3"):
|
|
43
|
+
process_song(item_path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(folder: str):
|
|
47
|
+
if path.exists(folder):
|
|
48
|
+
print("Searching for duplicate songs...")
|
|
49
|
+
|
|
50
|
+
process_directory(folder)
|
|
51
|
+
|
|
52
|
+
print("Duplicate songs removed!")
|
|
53
|
+
else:
|
|
54
|
+
print("Folder not found.")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from spots_cli.services.youtube_metadata_service import YouTubeMetadataService
|
|
2
|
+
from spots_cli.services.youtube_search_service import YoutubeSearchService
|
|
3
|
+
from spots_cli.services.providers_metadata import ProvidersMetadata
|
|
4
|
+
from spots_cli.services.provider_search_container import ProviderSearchContainer
|
|
5
|
+
from spots_cli.services.deezer_search_service import DeezerSearchService
|
|
6
|
+
from spots_cli.services.deezer_metadata_service import DeezerMetadataService
|
|
7
|
+
from spots_cli.services.spotify_search_service import SpotifySearchService
|
|
8
|
+
from spots_cli.services.spotify_metadata_service import SpotifyMetadataService
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import Any, cast, overload, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from spots_cli.models import SongNotFound, Metadata, Sentinel, MetadataProvider
|
|
7
|
+
from spots_cli.utils import fetch_data, FetchResponseFailure, FetchResponseSuccess
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from spots_cli.bootstrap.container import Clients, Core
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DeezerMetadataService(MetadataProvider):
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
clients: Clients,
|
|
21
|
+
core: Core,
|
|
22
|
+
):
|
|
23
|
+
self.clients = clients
|
|
24
|
+
self.core = core
|
|
25
|
+
|
|
26
|
+
@overload
|
|
27
|
+
def get(self, *, track_id: str, search_result: None = None) -> Metadata: ...
|
|
28
|
+
|
|
29
|
+
@overload
|
|
30
|
+
def get(
|
|
31
|
+
self, *, track_id: None = None, search_result: dict[str, Any]
|
|
32
|
+
) -> Metadata: ...
|
|
33
|
+
|
|
34
|
+
def get(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
track_id: str | None = None,
|
|
38
|
+
search_result: dict[str, Any] | None = None,
|
|
39
|
+
) -> Metadata:
|
|
40
|
+
logger.debug("Searching for metadata on Deezer...")
|
|
41
|
+
|
|
42
|
+
if track_id is not None:
|
|
43
|
+
query_id = track_id
|
|
44
|
+
elif search_result is not None:
|
|
45
|
+
query_id = search_result["id"]
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError("Either track_id or search_result must be provided")
|
|
48
|
+
|
|
49
|
+
# check cache first
|
|
50
|
+
url = "https://open.deezer.com/track/" + query_id
|
|
51
|
+
cache = self.core.storage.get(query=url, query_type="metadata")
|
|
52
|
+
if isinstance(cache, Sentinel):
|
|
53
|
+
raise SongNotFound(query_id)
|
|
54
|
+
elif isinstance(cache, Metadata):
|
|
55
|
+
return cache
|
|
56
|
+
|
|
57
|
+
# search song if id provided
|
|
58
|
+
if track_id is not None:
|
|
59
|
+
# retrieve track from deezer
|
|
60
|
+
uri = f"https://api.deezer.com/track/{track_id}"
|
|
61
|
+
track_res = fetch_data(uri)
|
|
62
|
+
|
|
63
|
+
if isinstance(track_res, FetchResponseFailure):
|
|
64
|
+
track_res = cast(FetchResponseFailure, track_res)
|
|
65
|
+
error_msg = track_res.error
|
|
66
|
+
|
|
67
|
+
if error_msg == "no data":
|
|
68
|
+
raise SongNotFound(f"Id {track_id}")
|
|
69
|
+
else:
|
|
70
|
+
raise Exception(f"Unknown error occurred: {error_msg}")
|
|
71
|
+
|
|
72
|
+
track_res = cast(FetchResponseSuccess, track_res)
|
|
73
|
+
track = cast(dict[str, Any], track_res.data)
|
|
74
|
+
elif search_result is not None:
|
|
75
|
+
track = search_result
|
|
76
|
+
else:
|
|
77
|
+
raise ValueError("Either track_id or search_result must be provided")
|
|
78
|
+
|
|
79
|
+
album_info = track["album"]
|
|
80
|
+
album_name = album_info["title"]
|
|
81
|
+
cover = album_info["cover_xl"]
|
|
82
|
+
|
|
83
|
+
artist_info = track["artist"]
|
|
84
|
+
artist_name = artist_info["name"]
|
|
85
|
+
track_name = track["title"]
|
|
86
|
+
|
|
87
|
+
# get track lyrics
|
|
88
|
+
try:
|
|
89
|
+
lyrics = self.core.lyrics.get_lyrics(title=track_name, artist=artist_name)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Error occurred while searching for lyrics", e)
|
|
92
|
+
lyrics = ""
|
|
93
|
+
|
|
94
|
+
metadata = Metadata(
|
|
95
|
+
title=track_name,
|
|
96
|
+
artist=artist_name,
|
|
97
|
+
link=track["link"],
|
|
98
|
+
release_date=track["release_date"],
|
|
99
|
+
tracknumber=str(track["track_position"]),
|
|
100
|
+
cover=cover,
|
|
101
|
+
lyrics=lyrics,
|
|
102
|
+
album=album_name,
|
|
103
|
+
preview_url=track["preview"],
|
|
104
|
+
artist_cover=artist_info["picture"],
|
|
105
|
+
artist_id=artist_info["id"],
|
|
106
|
+
)
|
|
107
|
+
self.core.storage.new(query=url, result=metadata, query_type="metadata")
|
|
108
|
+
return metadata
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from typing import Any, TypedDict, cast, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from spots_cli.models import (
|
|
7
|
+
Metadata,
|
|
8
|
+
SearchProvider,
|
|
9
|
+
PlaylistInfo,
|
|
10
|
+
ArtistInfo,
|
|
11
|
+
MetadataProvider,
|
|
12
|
+
SongNotFound,
|
|
13
|
+
Sentinel,
|
|
14
|
+
InvalidSearchFormat,
|
|
15
|
+
)
|
|
16
|
+
from spots_cli.utils import search_fallbacks
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from spots_cli.bootstrap.container import Clients, Core
|
|
20
|
+
from spots_cli.models import SearchProvider
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DeezerResponseBase(TypedDict):
|
|
27
|
+
total: int
|
|
28
|
+
data: list[dict[str, Any]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeezerSearchService(SearchProvider):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
metadata: MetadataProvider,
|
|
36
|
+
clients: Clients,
|
|
37
|
+
core: Core,
|
|
38
|
+
fallback_providers: list[SearchProvider] = [],
|
|
39
|
+
):
|
|
40
|
+
self.fallback_providers = fallback_providers
|
|
41
|
+
self.clients = clients
|
|
42
|
+
self.core = core
|
|
43
|
+
self.metadata = metadata
|
|
44
|
+
|
|
45
|
+
def search_album(self, album_url: str) -> PlaylistInfo:
|
|
46
|
+
album_data = self.clients.deezer._get_resource_by_url(album_url)
|
|
47
|
+
|
|
48
|
+
album_metadata = [
|
|
49
|
+
self.metadata.get(search_result=result) for result in album_data["tracks"]
|
|
50
|
+
]
|
|
51
|
+
artist_name = album_data["artist"]["name"]
|
|
52
|
+
return PlaylistInfo(
|
|
53
|
+
name=album_data["title"],
|
|
54
|
+
cover=album_data["cover"],
|
|
55
|
+
provider_metadata=album_metadata,
|
|
56
|
+
youtube_metadata=[],
|
|
57
|
+
artist=artist_name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def search_playlist(self, playlist_url: str) -> PlaylistInfo:
|
|
61
|
+
playlist_data = self.clients.deezer._get_resource_by_url(playlist_url)
|
|
62
|
+
|
|
63
|
+
playlist_metadata = [
|
|
64
|
+
self.metadata.get(search_result=result)
|
|
65
|
+
for result in playlist_data["tracks"]
|
|
66
|
+
]
|
|
67
|
+
return PlaylistInfo(
|
|
68
|
+
name=playlist_data["title"],
|
|
69
|
+
cover=playlist_data["picture"],
|
|
70
|
+
provider_metadata=playlist_metadata,
|
|
71
|
+
youtube_metadata=[],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def search_artist(self, artist_query: str) -> ArtistInfo:
|
|
75
|
+
# deezer provides a list of search results
|
|
76
|
+
# so we use the first result
|
|
77
|
+
metadata = self.search_track(artist_query)
|
|
78
|
+
|
|
79
|
+
cover = cast(str, metadata.artist_cover)
|
|
80
|
+
return ArtistInfo(name=metadata.artist, cover=cover, id=metadata.artist_id)
|
|
81
|
+
|
|
82
|
+
def search_artist_top_tracks(self, *, artist_id: str) -> PlaylistInfo:
|
|
83
|
+
try:
|
|
84
|
+
artist_id_int = int(artist_id)
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise TypeError("Number ids required")
|
|
87
|
+
|
|
88
|
+
artist_info, top_tracks = self.clients.deezer.artist_top_tracks(artist_id_int)
|
|
89
|
+
|
|
90
|
+
# get metadata for each top track
|
|
91
|
+
top_tracks_playlist = [
|
|
92
|
+
self.metadata.get(track_id=top_track["id"]) for top_track in top_tracks
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
return PlaylistInfo(
|
|
96
|
+
cover=artist_info.cover,
|
|
97
|
+
name="Top Tracks",
|
|
98
|
+
artist=artist_info.name,
|
|
99
|
+
provider_metadata=top_tracks_playlist,
|
|
100
|
+
youtube_metadata=[],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def search_track(self, query: str) -> Metadata:
|
|
104
|
+
query = query.replace("–", "-") # `–` != `-`
|
|
105
|
+
|
|
106
|
+
if "-" not in query:
|
|
107
|
+
error_txt = "Search format: `Artist` - `Title`"
|
|
108
|
+
logger.error(error_txt)
|
|
109
|
+
raise InvalidSearchFormat()
|
|
110
|
+
|
|
111
|
+
cache = self.core.storage.get(query=query, query_type="metadata")
|
|
112
|
+
|
|
113
|
+
if isinstance(cache, Sentinel):
|
|
114
|
+
raise SongNotFound(query)
|
|
115
|
+
elif isinstance(cache, Metadata):
|
|
116
|
+
return cache
|
|
117
|
+
else:
|
|
118
|
+
try:
|
|
119
|
+
track_id = self.clients.deezer.search(query)
|
|
120
|
+
metadata = self.metadata.get(track_id=track_id)
|
|
121
|
+
self.core.storage.new(
|
|
122
|
+
query=query, result=metadata, query_type="metadata"
|
|
123
|
+
)
|
|
124
|
+
return metadata
|
|
125
|
+
except SongNotFound:
|
|
126
|
+
metadata = search_fallbacks(
|
|
127
|
+
query=query, providers=self.fallback_providers
|
|
128
|
+
)
|
|
129
|
+
self.core.storage.new(
|
|
130
|
+
query=query, result=metadata, query_type="metadata"
|
|
131
|
+
)
|
|
132
|
+
return metadata
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from spots_cli.bootstrap.container import Clients, Core
|
|
7
|
+
from spots_cli.models import SearchProvider, MetadataProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProviderSearchContainer:
|
|
11
|
+
def __init__(self, clients: Clients, metadata: MetadataProvider, core: Core):
|
|
12
|
+
from spots_cli.services import DeezerSearchService, SpotifySearchService
|
|
13
|
+
|
|
14
|
+
# Define all possible providers with availability checks
|
|
15
|
+
provider_registry: dict[str, tuple[type[SearchProvider], bool]] = {
|
|
16
|
+
"deezer": (DeezerSearchService, True), # always available
|
|
17
|
+
"spotify": (
|
|
18
|
+
SpotifySearchService,
|
|
19
|
+
bool(clients.spotify), # availability check
|
|
20
|
+
),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Filter only available providers
|
|
24
|
+
available_providers = {
|
|
25
|
+
name: cls
|
|
26
|
+
for name, (cls, is_available) in provider_registry.items()
|
|
27
|
+
if is_available
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if not available_providers:
|
|
31
|
+
raise RuntimeError("No search providers available")
|
|
32
|
+
|
|
33
|
+
# Resolve default provider safely
|
|
34
|
+
default_provider = clients.secrets.read(key="main_provider", alt="deezer")
|
|
35
|
+
|
|
36
|
+
if default_provider not in available_providers:
|
|
37
|
+
# fallback to first available provider
|
|
38
|
+
default_provider = next(iter(available_providers))
|
|
39
|
+
|
|
40
|
+
provider_cls = available_providers[default_provider]
|
|
41
|
+
|
|
42
|
+
# Remove main from fallback pool
|
|
43
|
+
fallback_classes = {
|
|
44
|
+
name: cls
|
|
45
|
+
for name, cls in available_providers.items()
|
|
46
|
+
if name != default_provider
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Instantiate fallback providers
|
|
50
|
+
fallback_providers = [
|
|
51
|
+
cls(metadata=metadata, clients=clients, core=core)
|
|
52
|
+
for cls in fallback_classes.values()
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# Wire fallbacks (optional, depending on your design)
|
|
56
|
+
for provider in fallback_providers:
|
|
57
|
+
provider.fallback_providers = fallback_providers
|
|
58
|
+
|
|
59
|
+
# Instantiate main provider
|
|
60
|
+
self.main = provider_cls(
|
|
61
|
+
metadata=metadata,
|
|
62
|
+
clients=clients,
|
|
63
|
+
core=core,
|
|
64
|
+
fallback_providers=fallback_providers,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.fallbacks = fallback_providers
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from spots_cli.models import MetadataProvider
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from spots_cli.bootstrap.container import Clients, Core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProvidersMetadata:
|
|
11
|
+
def __init__(self, *, clients: Clients, core: Core):
|
|
12
|
+
from spots_cli.services import DeezerMetadataService, SpotifyMetadataService
|
|
13
|
+
|
|
14
|
+
media_providers: dict[str, type[MetadataProvider]] = {
|
|
15
|
+
"deezer": DeezerMetadataService,
|
|
16
|
+
"spotify": SpotifyMetadataService,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
default_provider = clients.secrets.read(key="main_provider", alt="deezer")
|
|
20
|
+
|
|
21
|
+
provider_cls = media_providers[default_provider]
|
|
22
|
+
self.metadata = provider_cls(core=core, clients=clients)
|