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
@@ -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,4 @@
1
+ class Sentinel:
2
+ """Represents a missing song in cache"""
3
+
4
+ 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,2 @@
1
+ from spots_cli.scripts.add_to_history import main as update_history
2
+ from spots_cli.scripts.duplicate_songs_remover import main as remove_duplicate_songs
@@ -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)