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
spots_cli/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from logging import getLogger
|
|
2
|
+
from os.path import exists
|
|
3
|
+
|
|
4
|
+
from .bootstrap.container import Container
|
|
5
|
+
from .models import (
|
|
6
|
+
MediaResourceSingle,
|
|
7
|
+
SongNotFound,
|
|
8
|
+
TitleExistsError,
|
|
9
|
+
InvalidSearchFormat,
|
|
10
|
+
InvalidURL,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Spots:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
from spots_cli.setup_env import main as setup, FILENAME as setup_file
|
|
19
|
+
|
|
20
|
+
if not exists(setup_file):
|
|
21
|
+
setup()
|
|
22
|
+
|
|
23
|
+
self.container = Container()
|
|
24
|
+
self.downloader = self.container.app.downloader
|
|
25
|
+
|
|
26
|
+
def download(self, query: str):
|
|
27
|
+
"""Downloads a song
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
query (str): The query to be downloaded. Can be either a query to be searched for, or a direct Spotify or YouTube download url.
|
|
31
|
+
"""
|
|
32
|
+
# handle direct link
|
|
33
|
+
if "https://" in query:
|
|
34
|
+
logger.info(f"Processing url: {query}")
|
|
35
|
+
try:
|
|
36
|
+
resolved_url = self.container.app.resolver.resolve(url=query)
|
|
37
|
+
except InvalidURL as e:
|
|
38
|
+
logger.info(e)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
# handle single
|
|
42
|
+
if isinstance(resolved_url, MediaResourceSingle):
|
|
43
|
+
try:
|
|
44
|
+
logger.info(f"{query} found, downloading now...")
|
|
45
|
+
self.downloader.download(
|
|
46
|
+
video_info=resolved_url.video_info,
|
|
47
|
+
metadata=resolved_url.metadata,
|
|
48
|
+
)
|
|
49
|
+
self.container.core.storage.save()
|
|
50
|
+
except (TitleExistsError, SongNotFound) as e:
|
|
51
|
+
logger.info(e)
|
|
52
|
+
return
|
|
53
|
+
else:
|
|
54
|
+
playlist_info = resolved_url.playlist_info
|
|
55
|
+
playlist_songs = zip(
|
|
56
|
+
playlist_info.provider_metadata, playlist_info.youtube_metadata
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for index, [provider, youtube] in enumerate(playlist_songs):
|
|
60
|
+
logger.info(
|
|
61
|
+
f"Processing song: {index + 1}/{len(playlist_info.provider_metadata)}"
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
self.downloader.download(video_info=youtube, metadata=provider)
|
|
65
|
+
except (SongNotFound, TitleExistsError) as e:
|
|
66
|
+
logger.info(e)
|
|
67
|
+
continue
|
|
68
|
+
self.container.core.storage.save()
|
|
69
|
+
# search query
|
|
70
|
+
else:
|
|
71
|
+
try:
|
|
72
|
+
logger.info(f"Searching for {query}...")
|
|
73
|
+
provider_result = self.container.domain.provider_search.search_track(
|
|
74
|
+
query
|
|
75
|
+
)
|
|
76
|
+
except SongNotFound:
|
|
77
|
+
logger.debug(
|
|
78
|
+
"Query not found by provider, falling back to YouTube video details."
|
|
79
|
+
)
|
|
80
|
+
provider_result = None
|
|
81
|
+
except InvalidSearchFormat as e:
|
|
82
|
+
logger.error(e)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
search_result = self.container.domain.youtube_search.video_search(
|
|
86
|
+
query=query, is_general_search=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if search_result.is_cached:
|
|
90
|
+
best_match = search_result.result[0]
|
|
91
|
+
else:
|
|
92
|
+
best_match = (
|
|
93
|
+
self.container.core.matcher.find_best_match(
|
|
94
|
+
search_results=search_result.result, metadata=provider_result
|
|
95
|
+
)
|
|
96
|
+
if provider_result
|
|
97
|
+
else search_result.result[0]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
metadata = (
|
|
101
|
+
provider_result
|
|
102
|
+
if provider_result
|
|
103
|
+
else self.container.domain.youtube_metadata.get(best_match)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
logger.info(f"{query} found, downloading now...")
|
|
108
|
+
self.downloader.download(video_info=best_match, metadata=metadata)
|
|
109
|
+
except (SongNotFound, TitleExistsError) as e:
|
|
110
|
+
logger.info(e)
|
|
111
|
+
|
|
112
|
+
self.container.core.storage.new(
|
|
113
|
+
query=query, result=best_match, query_type="youtube"
|
|
114
|
+
)
|
|
115
|
+
self.container.core.storage.save()
|
|
116
|
+
|
|
117
|
+
def transfer_spotify_likes(self):
|
|
118
|
+
"""Transfers all spotify likes to YouTube library"""
|
|
119
|
+
try:
|
|
120
|
+
self.container.app.youtube_user_playlist.transfer_spotify_likes_to_yt()
|
|
121
|
+
except SongNotFound:
|
|
122
|
+
logger.info("No liked songs found.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["Spots"]
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from spots_cli.app.media_resolver import MediaResolver
|
|
2
|
+
from spots_cli.app.downloader import Downloader
|
|
3
|
+
from spots_cli.app.youtube_user_playlist import YouTubeUserPlaylist
|
|
4
|
+
from spots_cli.app.spotify_playlist_compilation import SpotifyPlaylistCompilation
|
|
5
|
+
from spots_cli.app.domain_resolver import DomainResolver
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Sequence, overload
|
|
5
|
+
|
|
6
|
+
from spots_cli.models import Metadata, SongNotFound, Sentinel, YTVideoInfo
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from spots_cli.bootstrap.container import Core
|
|
10
|
+
from spots_cli.models import SearchProvider
|
|
11
|
+
from spots_cli.services import YoutubeSearchService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MatchingDomainResults:
|
|
16
|
+
provider: list[Metadata]
|
|
17
|
+
youtube: list[YTVideoInfo]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ResolvedDomainResult:
|
|
22
|
+
provider: Metadata
|
|
23
|
+
youtube: YTVideoInfo
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DomainResolver:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
core: Core,
|
|
30
|
+
provider_search: SearchProvider,
|
|
31
|
+
youtube_search: YoutubeSearchService,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.core = core
|
|
34
|
+
self.provider_search = provider_search
|
|
35
|
+
self.youtube_search = youtube_search
|
|
36
|
+
|
|
37
|
+
@overload
|
|
38
|
+
def filter_matching_domain_results(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
provider_results: Sequence[Metadata | Sentinel],
|
|
42
|
+
youtube_results: None = None,
|
|
43
|
+
) -> MatchingDomainResults: ...
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def filter_matching_domain_results(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
provider_results: None = None,
|
|
50
|
+
youtube_results: Sequence[YTVideoInfo | Sentinel],
|
|
51
|
+
) -> MatchingDomainResults: ...
|
|
52
|
+
|
|
53
|
+
def filter_matching_domain_results(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
provider_results: Sequence[Metadata | Sentinel] | None = None,
|
|
57
|
+
youtube_results: Sequence[YTVideoInfo | Sentinel] | None = None,
|
|
58
|
+
) -> MatchingDomainResults:
|
|
59
|
+
provider_playlist: list[Metadata] = []
|
|
60
|
+
youtube_videos: list[YTVideoInfo] = []
|
|
61
|
+
|
|
62
|
+
if youtube_results is not None:
|
|
63
|
+
for index, video in enumerate(youtube_results):
|
|
64
|
+
if isinstance(video, Sentinel):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
video_title = video.full_title
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
spotify_search_result = self.provider_search.search_track(
|
|
71
|
+
video_title
|
|
72
|
+
)
|
|
73
|
+
except SongNotFound:
|
|
74
|
+
self.core.storage.new(
|
|
75
|
+
query=video_title, result=Sentinel(), query_type="metadata"
|
|
76
|
+
)
|
|
77
|
+
self.core.storage.new(
|
|
78
|
+
query=video_title, result=Sentinel(), query_type="youtube"
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
tracks_match = self.core.matcher.match_tracks(
|
|
83
|
+
video_info=video, metadata=spotify_search_result
|
|
84
|
+
)
|
|
85
|
+
if not tracks_match:
|
|
86
|
+
self.core.storage.new(
|
|
87
|
+
query=video_title, result=Sentinel(), query_type="metadata"
|
|
88
|
+
)
|
|
89
|
+
self.core.storage.new(
|
|
90
|
+
query=video_title, result=Sentinel(), query_type="youtube"
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
else:
|
|
94
|
+
self.core.storage.new(
|
|
95
|
+
query=video_title,
|
|
96
|
+
result=spotify_search_result,
|
|
97
|
+
query_type="metadata",
|
|
98
|
+
)
|
|
99
|
+
self.core.storage.new(
|
|
100
|
+
query=video_title, result=video, query_type="youtube"
|
|
101
|
+
)
|
|
102
|
+
provider_playlist.append(spotify_search_result)
|
|
103
|
+
youtube_videos.append(video)
|
|
104
|
+
|
|
105
|
+
if (index % 10 == 0) or index == (len(youtube_results) - 1):
|
|
106
|
+
self.core.storage.save()
|
|
107
|
+
|
|
108
|
+
if provider_results is not None:
|
|
109
|
+
for track in provider_results:
|
|
110
|
+
if isinstance(track, Sentinel):
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
provider_title = track.full_title
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
youtube_search_results = self.youtube_search.video_search(
|
|
117
|
+
query=provider_title, is_general_search=True
|
|
118
|
+
)
|
|
119
|
+
except SongNotFound:
|
|
120
|
+
self.core.storage.new(
|
|
121
|
+
query=provider_title, result=Sentinel(), query_type="metadata"
|
|
122
|
+
)
|
|
123
|
+
self.core.storage.new(
|
|
124
|
+
query=provider_title, result=Sentinel(), query_type="youtube"
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if youtube_search_results.is_cached:
|
|
129
|
+
cache = youtube_search_results.result[0]
|
|
130
|
+
if isinstance(cache, Sentinel):
|
|
131
|
+
continue
|
|
132
|
+
else:
|
|
133
|
+
provider_playlist.append(track)
|
|
134
|
+
youtube_videos.append(cache)
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
try:
|
|
138
|
+
best_match = self.core.matcher.find_best_match(
|
|
139
|
+
search_results=youtube_search_results.result, metadata=track
|
|
140
|
+
)
|
|
141
|
+
except SongNotFound:
|
|
142
|
+
self.core.storage.new(
|
|
143
|
+
query=provider_title,
|
|
144
|
+
result=Sentinel(),
|
|
145
|
+
query_type="metadata",
|
|
146
|
+
)
|
|
147
|
+
self.core.storage.new(
|
|
148
|
+
query=provider_title,
|
|
149
|
+
result=Sentinel(),
|
|
150
|
+
query_type="youtube",
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
provider_playlist.append(track)
|
|
155
|
+
youtube_videos.append(best_match)
|
|
156
|
+
|
|
157
|
+
return MatchingDomainResults(provider=provider_playlist, youtube=youtube_videos)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hashlib import md5
|
|
4
|
+
from os.path import join, exists
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from spots_cli.models import TitleExistsError, Metadata, YTVideoInfo
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from spots_cli.bootstrap.container import Core, Clients
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Downloader:
|
|
15
|
+
"""A service for interacting with YouTube."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, core: Core, clients: Clients):
|
|
18
|
+
self.core = core
|
|
19
|
+
self.clients = clients
|
|
20
|
+
|
|
21
|
+
def download(
|
|
22
|
+
self, *, video_info: YTVideoInfo, metadata: Metadata, directory_path: str = ""
|
|
23
|
+
) -> bool:
|
|
24
|
+
"""Downloads a YouTube video as mp3
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
video_info (YTVideoInfo): the extracted info.
|
|
28
|
+
directory_path (str, Optional): the folder to be downloaded to. Defaults to the root of the Music folder.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
bool: True if downloaded successfully.
|
|
32
|
+
"""
|
|
33
|
+
url = "https://youtube.com/watch?v=" + video_info.id
|
|
34
|
+
title = f"{video_info.uploader} - {video_info.title}"
|
|
35
|
+
|
|
36
|
+
# normalize title
|
|
37
|
+
# '/' will read file name as folder in *nix systems
|
|
38
|
+
filename = title.replace("/", "|")
|
|
39
|
+
# Check if the file name length is too long, truncate if necessary
|
|
40
|
+
max_filename_length = 255 # Maximum allowed file name length on most systems
|
|
41
|
+
if len(filename) > max_filename_length:
|
|
42
|
+
file_hash = md5(filename.encode()).hexdigest()
|
|
43
|
+
filename = file_hash[:25]
|
|
44
|
+
|
|
45
|
+
# check if downloaded already
|
|
46
|
+
if self.core.history.read(filename):
|
|
47
|
+
raise TitleExistsError(filename)
|
|
48
|
+
|
|
49
|
+
download_folder = Path.home() / "Downloads"
|
|
50
|
+
download_folder = download_folder if exists(download_folder) else Path.home()
|
|
51
|
+
|
|
52
|
+
download_path = join(
|
|
53
|
+
download_folder, directory_path, f"{filename}.{video_info.audio_ext}"
|
|
54
|
+
)
|
|
55
|
+
converted_path = join(download_folder, directory_path, f"{filename}.mp3")
|
|
56
|
+
|
|
57
|
+
# set template for download titles
|
|
58
|
+
self.clients.ytdlp.options = {"outtmpl": download_path}
|
|
59
|
+
|
|
60
|
+
# download video
|
|
61
|
+
self.clients.ytdlp.client.download([url])
|
|
62
|
+
|
|
63
|
+
self.clients.ytdlp.reset_options()
|
|
64
|
+
|
|
65
|
+
# post download processing
|
|
66
|
+
converted = self.core.converter.convert_to_mp3(
|
|
67
|
+
old_file=download_path, new_file=converted_path
|
|
68
|
+
)
|
|
69
|
+
if converted:
|
|
70
|
+
metadata_updated = self.core.converter.update_metadata(
|
|
71
|
+
audio_path=converted_path, metadata=metadata
|
|
72
|
+
)
|
|
73
|
+
if metadata_updated:
|
|
74
|
+
self.core.history.write(filename)
|
|
75
|
+
else:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
return True
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from os.path import join
|
|
5
|
+
from tenacity import stop_after_delay
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
from spots_cli.engine import retry
|
|
9
|
+
from spots_cli.models import (
|
|
10
|
+
InvalidURL,
|
|
11
|
+
SongNotFound,
|
|
12
|
+
MediaResourceSingle,
|
|
13
|
+
MediaResourcePlaylist,
|
|
14
|
+
PlaylistInfo,
|
|
15
|
+
Sentinel,
|
|
16
|
+
YTVideoInfo,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from spots_cli.bootstrap.container import Core, Domain, Clients
|
|
21
|
+
from spots_cli.app import DomainResolver
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MediaResolver:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
core: Core,
|
|
31
|
+
domain: Domain,
|
|
32
|
+
clients: Clients,
|
|
33
|
+
domain_resolver: DomainResolver,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.domain_resolver = domain_resolver
|
|
36
|
+
self.core = core
|
|
37
|
+
self.domain = domain
|
|
38
|
+
self.clients = clients
|
|
39
|
+
|
|
40
|
+
@retry(stop=stop_after_delay(60))
|
|
41
|
+
def resolve(self, *, url: str) -> MediaResourceSingle | MediaResourcePlaylist:
|
|
42
|
+
"""Converts a youtube or spotify url to mp3, or a youtube video to mp3
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
url (str): url to be converted
|
|
46
|
+
single: (bool, optional): don't search for recommended tracks if false. Defaults to False
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
InvalidURL: if provided url not available
|
|
50
|
+
"""
|
|
51
|
+
# process spotify link
|
|
52
|
+
if "spotify" in url:
|
|
53
|
+
# single
|
|
54
|
+
if "track" in url:
|
|
55
|
+
# retrieve Spotify data
|
|
56
|
+
logger.debug("Resource type: single")
|
|
57
|
+
|
|
58
|
+
track_id = url.split("/")[-1]
|
|
59
|
+
metadata = self.domain.provider_metadata.get(track_id=track_id)
|
|
60
|
+
|
|
61
|
+
# search on YouTube
|
|
62
|
+
youtube_results = self.domain.youtube_search.video_search(
|
|
63
|
+
query=metadata.full_title, is_general_search=True
|
|
64
|
+
)
|
|
65
|
+
best_match = self.core.matcher.find_best_match(
|
|
66
|
+
search_results=youtube_results.result, metadata=metadata
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return MediaResourceSingle(
|
|
70
|
+
resource_type="single",
|
|
71
|
+
metadata=metadata,
|
|
72
|
+
video_info=best_match,
|
|
73
|
+
)
|
|
74
|
+
# playlist
|
|
75
|
+
else:
|
|
76
|
+
logger.debug("Resource type: playlist")
|
|
77
|
+
playlist_info = self.domain.provider_search.search_playlist(url)
|
|
78
|
+
|
|
79
|
+
return MediaResourcePlaylist(
|
|
80
|
+
resource_type="playlist", playlist_info=playlist_info
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# process youtube link
|
|
84
|
+
elif "youtu" in url:
|
|
85
|
+
logger.debug("URL type: YouTube")
|
|
86
|
+
# playlist
|
|
87
|
+
if "playlist" in url:
|
|
88
|
+
logger.debug("Resource type: playlist")
|
|
89
|
+
playlist_search = self.clients.ytdlp.client.extract_info(
|
|
90
|
+
url, download=False
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if not playlist_search:
|
|
94
|
+
raise SongNotFound(url)
|
|
95
|
+
|
|
96
|
+
playlist_search = cast(dict[str, Any], playlist_search)
|
|
97
|
+
|
|
98
|
+
videos_list = [
|
|
99
|
+
YTVideoInfo(
|
|
100
|
+
id=result["id"],
|
|
101
|
+
filesize=self.domain.youtube_search.get_video_size(result),
|
|
102
|
+
title=result["title"],
|
|
103
|
+
uploader=result["uploader"],
|
|
104
|
+
audio_ext=result["audio_ext"],
|
|
105
|
+
)
|
|
106
|
+
for result in playlist_search["entries"]
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
domain_matches = self.domain_resolver.filter_matching_domain_results(
|
|
110
|
+
youtube_results=videos_list
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
cover = self.clients.secrets.read(
|
|
114
|
+
key="static_playlist_cover_name", alt="youtube-playlist.jpg"
|
|
115
|
+
)
|
|
116
|
+
playlist_name = playlist_search["title"]
|
|
117
|
+
|
|
118
|
+
playlist_info = PlaylistInfo(
|
|
119
|
+
name=playlist_name,
|
|
120
|
+
cover=cover,
|
|
121
|
+
provider_metadata=domain_matches.provider,
|
|
122
|
+
youtube_metadata=domain_matches.youtube,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return MediaResourcePlaylist(
|
|
126
|
+
resource_type="playlist", playlist_info=playlist_info
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
logger.debug("Resource type: single")
|
|
130
|
+
|
|
131
|
+
video_info = self.domain.youtube_search.video_search(
|
|
132
|
+
query=url, is_general_search=False
|
|
133
|
+
)
|
|
134
|
+
if video_info.is_cached:
|
|
135
|
+
cached_metadata = self.core.storage.get(
|
|
136
|
+
query=url, query_type="metadata"
|
|
137
|
+
)
|
|
138
|
+
return MediaResourceSingle(
|
|
139
|
+
resource_type="single",
|
|
140
|
+
metadata=cached_metadata,
|
|
141
|
+
video_info=video_info.result,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
yt_title = video_info.result.full_title
|
|
145
|
+
try:
|
|
146
|
+
metadata = self.domain.provider_search.search_track(yt_title)
|
|
147
|
+
except SongNotFound:
|
|
148
|
+
self.core.storage.new(
|
|
149
|
+
query=yt_title, result=Sentinel(), query_type="metadata"
|
|
150
|
+
)
|
|
151
|
+
self.core.storage.new(
|
|
152
|
+
query=yt_title, result=Sentinel(), query_type="youtube"
|
|
153
|
+
)
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
tracks_match = self.core.matcher.match_tracks(
|
|
157
|
+
metadata=metadata, video_info=video_info.result
|
|
158
|
+
)
|
|
159
|
+
if not tracks_match:
|
|
160
|
+
self.core.storage.new(
|
|
161
|
+
query=yt_title, result=Sentinel(), query_type="metadata"
|
|
162
|
+
)
|
|
163
|
+
self.core.storage.new(
|
|
164
|
+
query=yt_title, result=Sentinel(), query_type="youtube"
|
|
165
|
+
)
|
|
166
|
+
raise SongNotFound(yt_title)
|
|
167
|
+
else:
|
|
168
|
+
self.core.storage.new(
|
|
169
|
+
query=yt_title, result=metadata, query_type="metadata"
|
|
170
|
+
)
|
|
171
|
+
self.core.storage.new(
|
|
172
|
+
query=yt_title, result=video_info.result, query_type="youtube"
|
|
173
|
+
)
|
|
174
|
+
return MediaResourceSingle(
|
|
175
|
+
resource_type="single",
|
|
176
|
+
metadata=metadata,
|
|
177
|
+
video_info=video_info.result,
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
raise InvalidURL(url)
|