spots-cli 0.1.0__tar.gz → 0.2.0__tar.gz

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 (106) hide show
  1. {spots_cli-0.1.0 → spots_cli-0.2.0}/PKG-INFO +25 -11
  2. {spots_cli-0.1.0 → spots_cli-0.2.0}/README.md +22 -8
  3. {spots_cli-0.1.0 → spots_cli-0.2.0}/pyproject.toml +12 -2
  4. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/__init__.py +45 -0
  5. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/domain_resolver.py +41 -5
  6. spots_cli-0.2.0/src/spots_cli/app/downloader.py +260 -0
  7. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/media_resolver.py +64 -18
  8. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/bootstrap/container.py +3 -1
  9. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/cli.py +15 -1
  10. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/youtube.py +25 -3
  11. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/ytdl.py +19 -8
  12. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/video_converter.py +45 -1
  13. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/youtube_extractor.py +39 -6
  14. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/__init__.py +3 -0
  15. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/errors.py +10 -0
  16. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/playlist_info.py +5 -2
  17. spots_cli-0.2.0/src/spots_cli/models/unavailable_video.py +18 -0
  18. spots_cli-0.2.0/src/spots_cli/models/video_resolution.py +20 -0
  19. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/yt_video_info.py +1 -1
  20. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/youtube_metadata_service.py +4 -2
  21. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/setup_env.py +11 -0
  22. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/__init__.py +1 -0
  23. spots_cli-0.2.0/src/spots_cli/utils/detect_js_runtimes.py +35 -0
  24. spots_cli-0.2.0/tests/api/test_download_video.py +125 -0
  25. spots_cli-0.2.0/tests/app/test_downloader.py +149 -0
  26. spots_cli-0.2.0/tests/clients/test_deezer.py +61 -0
  27. spots_cli-0.2.0/tests/clients/test_secrets_manager.py +19 -0
  28. {spots_cli-0.1.0 → spots_cli-0.2.0}/tests/clients/test_spotify.py +11 -11
  29. spots_cli-0.2.0/tests/clients/test_youtube.py +124 -0
  30. spots_cli-0.2.0/tests/clients/test_ytdl.py +103 -0
  31. spots_cli-0.2.0/tests/conftest.py +50 -0
  32. spots_cli-0.2.0/tests/test_cli.py +37 -0
  33. spots_cli-0.2.0/tests/test_spots.py +64 -0
  34. spots_cli-0.2.0/tests/utils/test_detect_js_runtimes.py +44 -0
  35. spots_cli-0.1.0/api/main.py +0 -38
  36. spots_cli-0.1.0/api/server/__init__.py +0 -324
  37. spots_cli-0.1.0/api/server/download_music.py +0 -161
  38. spots_cli-0.1.0/api/server/static/Ball-1s-500px.svg +0 -6
  39. spots_cli-0.1.0/api/server/static/Magnify-1s-401px.svg +0 -14
  40. spots_cli-0.1.0/api/server/static/android-chrome-192x192.png +0 -0
  41. spots_cli-0.1.0/api/server/static/android-chrome-512x512.png +0 -0
  42. spots_cli-0.1.0/api/server/static/apple-touch-icon.png +0 -0
  43. spots_cli-0.1.0/api/server/static/arrow-down-circle-svgrepo-com.svg +0 -7
  44. spots_cli-0.1.0/api/server/static/avatar.jpg +0 -0
  45. spots_cli-0.1.0/api/server/static/download-svgrepo-com.svg +0 -19
  46. spots_cli-0.1.0/api/server/static/favicon-16x16.png +0 -0
  47. spots_cli-0.1.0/api/server/static/favicon-32x32.png +0 -0
  48. spots_cli-0.1.0/api/server/static/favicon.ico +0 -0
  49. spots_cli-0.1.0/api/server/static/house-svgrepo-com.svg +0 -7
  50. spots_cli-0.1.0/api/server/static/icon.jpg +0 -0
  51. spots_cli-0.1.0/api/server/static/single-cover(mdesigns).jpg +0 -0
  52. spots_cli-0.1.0/api/server/static/single-cover.jpg +0 -0
  53. spots_cli-0.1.0/api/server/static/youtube-playlist(mdesigns).jpg +0 -0
  54. spots_cli-0.1.0/api/server/static/youtube-playlist.jpg +0 -0
  55. spots_cli-0.1.0/api/site.webmanifest +0 -1
  56. spots_cli-0.1.0/requirements.txt +0 -64
  57. spots_cli-0.1.0/src/spots_cli/app/downloader.py +0 -78
  58. spots_cli-0.1.0/tests/clients/test_deezer.py +0 -11
  59. spots_cli-0.1.0/tests/clients/test_secrets_manager.py +0 -0
  60. spots_cli-0.1.0/tests/clients/test_youtube.py +0 -149
  61. spots_cli-0.1.0/tests/clients/test_ytdl.py +0 -155
  62. {spots_cli-0.1.0 → spots_cli-0.2.0}/.gitignore +0 -0
  63. {spots_cli-0.1.0 → spots_cli-0.2.0}/LICENSE +0 -0
  64. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/__init__.py +0 -0
  65. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/spotify_playlist_compilation.py +0 -0
  66. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/app/youtube_user_playlist.py +0 -0
  67. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/bootstrap/__init__.py +0 -0
  68. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/__init__.py +0 -0
  69. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/deezer.py +0 -0
  70. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/secrets_manager.py +0 -0
  71. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/clients/spotify.py +0 -0
  72. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/config/__init__.py +0 -0
  73. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/__init__.py +0 -0
  74. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/history_manager.py +0 -0
  75. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/lyrics_finder.py +0 -0
  76. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/pattern_matcher.py +0 -0
  77. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/core/web_scraper.py +0 -0
  78. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/__init__.py +0 -0
  79. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/file_storage.py +0 -0
  80. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/persistence_model.py +0 -0
  81. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/engine/retry.py +0 -0
  82. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/integrations/__init__.py +0 -0
  83. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/integrations/spotify_user_playlist_modify.py +0 -0
  84. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/artist_metadata.py +0 -0
  85. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/helper_models.py +0 -0
  86. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/media_resource.py +0 -0
  87. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/metadata.py +0 -0
  88. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/metadata_provider.py +0 -0
  89. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/search_provider.py +0 -0
  90. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/models/sentinel.py +0 -0
  91. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/__init__.py +0 -0
  92. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/add_to_history.py +0 -0
  93. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/bootstrap.py +0 -0
  94. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/scripts/duplicate_songs_remover.py +0 -0
  95. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/__init__.py +0 -0
  96. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/deezer_metadata_service.py +0 -0
  97. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/deezer_search_service.py +0 -0
  98. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/provider_search_container.py +0 -0
  99. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/providers_metadata.py +0 -0
  100. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/spotify_metadata_service.py +0 -0
  101. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/spotify_search_service.py +0 -0
  102. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/services/youtube_search_service.py +0 -0
  103. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/config_dir.py +0 -0
  104. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/detect_browser.py +0 -0
  105. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/fetch.py +0 -0
  106. {spots_cli-0.1.0 → spots_cli-0.2.0}/src/spots_cli/utils/search_fallbacks.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: spots_cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Spots is a modular music coordination library that unifies multiple providers (e.g. Deezer, Spotify, YouTube) into a single, consistent interface.
5
5
  Project-URL: Homepage, https://github.com/mdesignscode/spots_flask
6
6
  Project-URL: Issues, https://github.com/mdesignscode/spots_flask
@@ -24,7 +24,7 @@ Requires-Dist: requests
24
24
  Requires-Dist: spotipy
25
25
  Requires-Dist: tenacity
26
26
  Requires-Dist: typer
27
- Requires-Dist: yt-dlp
27
+ Requires-Dist: yt-dlp[default]>=2026.10.26
28
28
  Description-Content-Type: text/markdown
29
29
 
30
30
  # 🎧 Spots
@@ -63,7 +63,7 @@ It resolves tracks across platforms, matches metadata intelligently, and enables
63
63
  ## 📦 Installation
64
64
 
65
65
  ```bash
66
- pip install spots
66
+ pip install spots-cli
67
67
  ```
68
68
 
69
69
  ---
@@ -73,22 +73,32 @@ pip install spots
73
73
  ### Run the setup script:
74
74
 
75
75
  ```bash
76
- spots setup
76
+ spots_cli setup
77
77
  ```
78
78
 
79
79
  * follow the prompts to generate the config folder
80
80
 
81
81
 
82
+ ### YouTube account features (optional)
83
+
84
+ Migrating likes uses your own Google credentials. In Google Cloud Console, enable the YouTube Data API v3, create an OAuth client ID of type *Desktop app*, and save its JSON as `client_secrets.json` in the Spots config folder:
85
+
86
+ * Linux: `~/.config/spots_cli/`
87
+ * macOS: `~/Library/Application Support/spots_cli/`
88
+ * Windows: `%APPDATA%\spots_cli\`
89
+
90
+ Then answer "y" to the YouTube question in `spots_cli setup`. You'll sign in to YouTube in your browser the first time.
91
+
82
92
  ---
83
93
 
84
94
  ## 🚀 Usage
85
95
  ```bash
86
96
  # search a title
87
97
  # required search format: `Artist - Title`
88
- spots download "Kelsey Lynn - Modern Day Marilyn Monroe"
98
+ spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
89
99
 
90
100
  # download a direct url
91
- spots download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
101
+ spots_cli download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
92
102
  ```
93
103
 
94
104
  Spots will:
@@ -104,19 +114,23 @@ Spots will:
104
114
 
105
115
  * download - downloads a direct url or a search query
106
116
  ```bash
107
- spots download "Kelsey Lynn - Modern Day Marilyn Monroe"
117
+ spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
118
+ ```
119
+ * download-video - downloads a YouTube video (a url, or search terms) as mp4. Picks the highest resolution unless you pass `--resolution`
120
+ ```bash
121
+ spots_cli download-video "https://www.youtube.com/watch?v=UmZPa5Qudtw" --resolution 720p
108
122
  ```
109
123
  * migrate-likes - transfers likes from Spotify to YouTube (more providers coming soon...)
110
124
  ```bash
111
- spots migrate-likes
125
+ spots_cli migrate-likes
112
126
  ```
113
127
  * add-to-history - adds a user's downloaded songs to the Spots history. Avoids duplicate downloads.
114
128
  ```bash
115
- spots add-to-history path-to-music
129
+ spots_cli add-to-history path-to-music
116
130
  ```
117
131
  * remove-duplicates - removes duplicate songs in a given folder.
118
132
  ```bash
119
- spots remove-duplicates path-to-music
133
+ spots_cli remove-duplicates path-to-music
120
134
  ```
121
135
 
122
136
  ---
@@ -34,7 +34,7 @@ It resolves tracks across platforms, matches metadata intelligently, and enables
34
34
  ## 📦 Installation
35
35
 
36
36
  ```bash
37
- pip install spots
37
+ pip install spots-cli
38
38
  ```
39
39
 
40
40
  ---
@@ -44,22 +44,32 @@ pip install spots
44
44
  ### Run the setup script:
45
45
 
46
46
  ```bash
47
- spots setup
47
+ spots_cli setup
48
48
  ```
49
49
 
50
50
  * follow the prompts to generate the config folder
51
51
 
52
52
 
53
+ ### YouTube account features (optional)
54
+
55
+ Migrating likes uses your own Google credentials. In Google Cloud Console, enable the YouTube Data API v3, create an OAuth client ID of type *Desktop app*, and save its JSON as `client_secrets.json` in the Spots config folder:
56
+
57
+ * Linux: `~/.config/spots_cli/`
58
+ * macOS: `~/Library/Application Support/spots_cli/`
59
+ * Windows: `%APPDATA%\spots_cli\`
60
+
61
+ Then answer "y" to the YouTube question in `spots_cli setup`. You'll sign in to YouTube in your browser the first time.
62
+
53
63
  ---
54
64
 
55
65
  ## 🚀 Usage
56
66
  ```bash
57
67
  # search a title
58
68
  # required search format: `Artist - Title`
59
- spots download "Kelsey Lynn - Modern Day Marilyn Monroe"
69
+ spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
60
70
 
61
71
  # download a direct url
62
- spots download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
72
+ spots_cli download "https://www.youtube.com/watch?v=UmZPa5Qudtw"
63
73
  ```
64
74
 
65
75
  Spots will:
@@ -75,19 +85,23 @@ Spots will:
75
85
 
76
86
  * download - downloads a direct url or a search query
77
87
  ```bash
78
- spots download "Kelsey Lynn - Modern Day Marilyn Monroe"
88
+ spots_cli download "Kelsey Lynn - Modern Day Marilyn Monroe"
89
+ ```
90
+ * download-video - downloads a YouTube video (a url, or search terms) as mp4. Picks the highest resolution unless you pass `--resolution`
91
+ ```bash
92
+ spots_cli download-video "https://www.youtube.com/watch?v=UmZPa5Qudtw" --resolution 720p
79
93
  ```
80
94
  * migrate-likes - transfers likes from Spotify to YouTube (more providers coming soon...)
81
95
  ```bash
82
- spots migrate-likes
96
+ spots_cli migrate-likes
83
97
  ```
84
98
  * add-to-history - adds a user's downloaded songs to the Spots history. Avoids duplicate downloads.
85
99
  ```bash
86
- spots add-to-history path-to-music
100
+ spots_cli add-to-history path-to-music
87
101
  ```
88
102
  * remove-duplicates - removes duplicate songs in a given folder.
89
103
  ```bash
90
- spots remove-duplicates path-to-music
104
+ spots_cli remove-duplicates path-to-music
91
105
  ```
92
106
 
93
107
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "spots_cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  authors = [
9
9
  { name="Marlon Baatjes", email="marlonbaatjes62@gmail.com" }
10
10
  ]
@@ -30,10 +30,13 @@ dependencies = [
30
30
  "requests",
31
31
  "spotipy",
32
32
  "tenacity",
33
- "yt-dlp",
33
+ "yt-dlp[default]>=2026.10.26",
34
34
  "typer"
35
35
  ]
36
36
 
37
+ [dependency-groups]
38
+ test = ["pytest"]
39
+
37
40
  [project.urls]
38
41
  Homepage = "https://github.com/mdesignscode/spots_flask"
39
42
  Issues = "https://github.com/mdesignscode/spots_flask"
@@ -41,6 +44,13 @@ Issues = "https://github.com/mdesignscode/spots_flask"
41
44
  [tool.setuptools.packages.find]
42
45
  where = ["src"]
43
46
 
47
+ [tool.hatch.build.targets.sdist]
48
+ # only the package, its tests and docs; not the API, deploy files or local scratch files
49
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+
44
54
  [tool.mypy]
45
55
  explicit_package_bases = true
46
56
 
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  from logging import getLogger
2
4
  from os.path import exists
3
5
 
@@ -8,6 +10,7 @@ from .models import (
8
10
  TitleExistsError,
9
11
  InvalidSearchFormat,
10
12
  InvalidURL,
13
+ ResolutionUnavailable,
11
14
  )
12
15
 
13
16
  logger = getLogger(__name__)
@@ -114,6 +117,41 @@ class Spots:
114
117
  )
115
118
  self.container.core.storage.save()
116
119
 
120
+ def download_video(
121
+ self, query: str, resolution: str | None = None, directory_path: str = ""
122
+ ) -> str | None:
123
+ """Downloads a YouTube video as mp4
124
+
125
+ Args:
126
+ query (str): A YouTube video url, or search terms (the top result is used).
127
+ resolution (str, Optional): e.g. "720p". Defaults to the highest available.
128
+ directory_path (str, Optional): sub-folder of the downloads folder.
129
+
130
+ Returns:
131
+ str | None: the downloaded file's path, or None if nothing was downloaded.
132
+ """
133
+ is_url = query.startswith(("http://", "https://"))
134
+
135
+ try:
136
+ logger.info(f"Searching for {query}...")
137
+ search = self.container.domain.youtube_search.video_search(
138
+ query=query, is_general_search=not is_url
139
+ )
140
+ video = search.result if is_url else search.result[0]
141
+
142
+ logger.info(f"Downloading {video.full_title}...")
143
+ path = self.downloader.download_video(
144
+ video_info=video, resolution=resolution, directory_path=directory_path
145
+ )
146
+ except (SongNotFound, ResolutionUnavailable) as e:
147
+ logger.error(e)
148
+ return None
149
+ finally:
150
+ self.container.core.storage.save()
151
+
152
+ logger.info(f"Saved to {path}")
153
+ return path
154
+
117
155
  def transfer_spotify_likes(self):
118
156
  """Transfers all spotify likes to YouTube library"""
119
157
  try:
@@ -121,5 +159,12 @@ class Spots:
121
159
  except SongNotFound:
122
160
  logger.info("No liked songs found.")
123
161
 
162
+ def download_spotify_likes(self):
163
+ """Downloads all Spotify likes"""
164
+ try:
165
+ self.container.app.youtube_user_playlist.transfer_spotify_likes_to_yt()
166
+ except SongNotFound:
167
+ logger.info("No liked songs found.")
168
+
124
169
 
125
170
  __all__ = ["Spots"]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from logging import getLogger
4
5
  from typing import TYPE_CHECKING, Sequence, overload
5
6
 
6
7
  from spots_cli.models import Metadata, SongNotFound, Sentinel, YTVideoInfo
@@ -11,6 +12,9 @@ if TYPE_CHECKING:
11
12
  from spots_cli.services import YoutubeSearchService
12
13
 
13
14
 
15
+ logger = getLogger(__name__)
16
+
17
+
14
18
  @dataclass
15
19
  class MatchingDomainResults:
16
20
  provider: list[Metadata]
@@ -23,6 +27,23 @@ class ResolvedDomainResult:
23
27
  youtube: YTVideoInfo
24
28
 
25
29
 
30
+ def _fallback_metadata(video: YTVideoInfo) -> Metadata:
31
+ """Builds a bare-bones Metadata object straight from a YouTube result.
32
+
33
+ Used when no provider (Spotify/Deezer) has metadata for a track, or the
34
+ provider's result doesn't match. The provider is only used to prettify
35
+ the library (cover art, album, lyrics, etc) - the audio always comes
36
+ from YouTube, so a missing/mismatched provider entry should never cause
37
+ a track to be dropped as long as it exists on YouTube.
38
+ """
39
+ return Metadata(
40
+ title=video.title,
41
+ artist=video.uploader,
42
+ link=video.video_link,
43
+ artist_id="",
44
+ )
45
+
46
+
26
47
  class DomainResolver:
27
48
  def __init__(
28
49
  self,
@@ -71,25 +92,40 @@ class DomainResolver:
71
92
  video_title
72
93
  )
73
94
  except SongNotFound:
95
+ logger.debug(
96
+ f"No provider metadata for '{video_title}', "
97
+ "falling back to YouTube-derived metadata"
98
+ )
99
+ fallback = _fallback_metadata(video)
74
100
  self.core.storage.new(
75
- query=video_title, result=Sentinel(), query_type="metadata"
101
+ query=video_title, result=fallback, query_type="metadata"
76
102
  )
77
103
  self.core.storage.new(
78
- query=video_title, result=Sentinel(), query_type="youtube"
104
+ query=video_title, result=video, query_type="youtube"
79
105
  )
106
+ provider_playlist.append(fallback)
107
+ youtube_videos.append(video)
108
+ if (index % 10 == 0) or index == (len(youtube_results) - 1):
109
+ self.core.storage.save()
80
110
  continue
81
111
 
82
112
  tracks_match = self.core.matcher.match_tracks(
83
113
  video_info=video, metadata=spotify_search_result
84
114
  )
85
115
  if not tracks_match:
116
+ logger.debug(
117
+ f"Provider metadata for '{video_title}' didn't match, "
118
+ "falling back to YouTube-derived metadata"
119
+ )
120
+ fallback = _fallback_metadata(video)
86
121
  self.core.storage.new(
87
- query=video_title, result=Sentinel(), query_type="metadata"
122
+ query=video_title, result=fallback, query_type="metadata"
88
123
  )
89
124
  self.core.storage.new(
90
- query=video_title, result=Sentinel(), query_type="youtube"
125
+ query=video_title, result=video, query_type="youtube"
91
126
  )
92
- continue
127
+ provider_playlist.append(fallback)
128
+ youtube_videos.append(video)
93
129
  else:
94
130
  self.core.storage.new(
95
131
  query=video_title,
@@ -0,0 +1,260 @@
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 (
9
+ ResolutionUnavailable,
10
+ TitleExistsError,
11
+ Metadata,
12
+ VideoResolution,
13
+ YTVideoInfo,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from spots_cli.bootstrap.container import Core, Clients
18
+
19
+
20
+ class Downloader:
21
+ """A service for interacting with YouTube."""
22
+
23
+ def __init__(self, *, core: Core, clients: Clients):
24
+ self.core = core
25
+ self.clients = clients
26
+
27
+ @staticmethod
28
+ def _filename(video_info: YTVideoInfo) -> str:
29
+ """A file-system safe name for a video: "Uploader - Title"."""
30
+ # '/' will read file name as folder in *nix systems
31
+ filename = video_info.full_title.replace("/", "|")
32
+ # Check if the file name length is too long, truncate if necessary
33
+ max_filename_length = 255 # Maximum allowed file name length on most systems
34
+ if len(filename) > max_filename_length:
35
+ filename = md5(filename.encode()).hexdigest()[:25]
36
+ return filename
37
+
38
+ @staticmethod
39
+ def _download_folder(directory_path: str) -> str:
40
+ """The folder downloads are saved to: ~/Downloads (or ~), plus directory_path."""
41
+ download_folder = Path.home() / "Downloads"
42
+ if not exists(download_folder):
43
+ download_folder = Path.home()
44
+ return join(download_folder, directory_path)
45
+
46
+ def download(
47
+ self, *, video_info: YTVideoInfo, metadata: Metadata, directory_path: str = ""
48
+ ) -> bool:
49
+ """Downloads a YouTube video as mp3
50
+
51
+ Args:
52
+ video_info (YTVideoInfo): the extracted info.
53
+ directory_path (str, Optional): the folder to be downloaded to. Defaults to the root of the Music folder.
54
+
55
+ Returns:
56
+ bool: True if downloaded successfully.
57
+ """
58
+ url = video_info.video_link
59
+ filename = self._filename(video_info)
60
+
61
+ # check if downloaded already
62
+ if self.core.history.read(filename):
63
+ raise TitleExistsError(filename)
64
+
65
+ folder = self._download_folder(directory_path)
66
+ download_path = join(folder, f"{filename}.{video_info.audio_ext}")
67
+ converted_path = join(folder, f"{filename}.mp3")
68
+
69
+ # set template for download titles
70
+ self.clients.ytdlp.options = {"outtmpl": download_path}
71
+
72
+ # download video
73
+ self.clients.ytdlp.client.download([url])
74
+
75
+ self.clients.ytdlp.reset_options()
76
+
77
+ # post download processing
78
+ converted = self.core.converter.convert_to_mp3(
79
+ old_file=download_path, new_file=converted_path
80
+ )
81
+ if converted:
82
+ metadata_updated = self.core.converter.update_metadata(
83
+ audio_path=converted_path, metadata=metadata
84
+ )
85
+ if metadata_updated:
86
+ self.core.history.write(filename)
87
+ else:
88
+ return False
89
+
90
+ return True
91
+
92
+ def get_available_resolutions(
93
+ self, *, video_info: YTVideoInfo
94
+ ) -> dict[str, VideoResolution]:
95
+ """
96
+ Detects all available video resolutions for a YouTube video.
97
+
98
+ Args:
99
+ video_info (YTVideoInfo): the extracted video info (only .id is used
100
+ to look up the video).
101
+
102
+ Returns:
103
+ dict[str, VideoResolution]: maps a resolution label (e.g. "1080p")
104
+ to a VideoResolution describing the best available format at
105
+ that resolution. filesize_mb is reported in megabytes (MB).
106
+ Most high-resolution formats are video-only (acodec == "none");
107
+ download_video automatically merges in the best available audio
108
+ track for those.
109
+ """
110
+ url = video_info.video_link
111
+
112
+ self.clients.ytdlp.options = {"quiet": True, "noplaylist": True}
113
+ try:
114
+ info = self.clients.ytdlp.client.extract_info(url, download=False)
115
+ finally:
116
+ self.clients.ytdlp.reset_options()
117
+
118
+ all_formats = info.get("formats", [])
119
+
120
+ # Estimate the size of the audio track yt-dlp will pick for "bestaudio",
121
+ # so video-only formats can report a total (video + audio) download size
122
+ # instead of just the video stream's size.
123
+ best_audio_bytes = 0
124
+ best_audio_abr = -1.0
125
+ for fmt in all_formats:
126
+ is_audio_only = fmt.get("vcodec") in (None, "none") and fmt.get(
127
+ "acodec"
128
+ ) not in (None, "none")
129
+ if not is_audio_only:
130
+ continue
131
+ abr = fmt.get("abr") or 0
132
+ if abr > best_audio_abr:
133
+ best_audio_abr = abr
134
+ best_audio_bytes = (
135
+ fmt.get("filesize") or fmt.get("filesize_approx") or 0
136
+ )
137
+
138
+ resolutions: dict[str, VideoResolution] = {}
139
+ for fmt in all_formats:
140
+ height = fmt.get("height")
141
+ # skip audio-only formats and formats with no video track
142
+ if not height or fmt.get("vcodec") in (None, "none"):
143
+ continue
144
+
145
+ label = f"{height}p"
146
+ is_video_only = fmt.get("acodec") in (None, "none")
147
+ video_bytes = fmt.get("filesize") or fmt.get(
148
+ "filesize_approx") or 0
149
+ # video-only formats get merged with bestaudio on download, so
150
+ # reflect that in the reported size; progressive formats already
151
+ # include audio in their own filesize.
152
+ total_bytes = video_bytes + best_audio_bytes if is_video_only else video_bytes
153
+
154
+ existing = resolutions.get(label)
155
+ existing_bytes = (existing.filesize_mb or 0) * \
156
+ 1024 * 1024 if existing else -1
157
+
158
+ # keep the best-quality format available for each resolution
159
+ if not existing or total_bytes > existing_bytes:
160
+ resolutions[label] = VideoResolution(
161
+ format_id=fmt.get("format_id"),
162
+ height=height,
163
+ ext=fmt.get("ext"),
164
+ vcodec=fmt.get("vcodec"),
165
+ acodec=fmt.get("acodec"),
166
+ fps=fmt.get("fps"),
167
+ filesize_mb=(
168
+ round(total_bytes / (1024 * 1024),
169
+ 2) if total_bytes else None
170
+ ),
171
+ )
172
+
173
+ return resolutions
174
+
175
+ @staticmethod
176
+ def pick_resolution(
177
+ resolutions: dict[str, VideoResolution], label: str | None = None
178
+ ) -> VideoResolution:
179
+ """
180
+ Chooses one resolution from get_available_resolutions().
181
+
182
+ Args:
183
+ resolutions: the available resolutions, keyed by label.
184
+ label (str, Optional): e.g. "720p" (a bare "720" is accepted too).
185
+ Defaults to the highest resolution available.
186
+
187
+ Raises:
188
+ ResolutionUnavailable: no resolutions, or `label` is not among them.
189
+ """
190
+ by_height = sorted(resolutions.values(), key=lambda r: r.height)
191
+ available = [r.label for r in by_height]
192
+
193
+ if label is None:
194
+ if not by_height:
195
+ raise ResolutionUnavailable("best", available)
196
+ return by_height[-1]
197
+
198
+ wanted = label.strip().lower()
199
+ if not wanted.endswith("p"):
200
+ wanted += "p"
201
+ if wanted not in resolutions:
202
+ raise ResolutionUnavailable(wanted, available)
203
+ return resolutions[wanted]
204
+
205
+ def download_video(
206
+ self,
207
+ *,
208
+ video_info: YTVideoInfo,
209
+ resolution: VideoResolution | str | None = None,
210
+ directory_path: str = "",
211
+ ) -> str:
212
+ """
213
+ Downloads a YouTube video as mp4 at a chosen resolution.
214
+
215
+ Args:
216
+ video_info (YTVideoInfo): the extracted info.
217
+ resolution (VideoResolution | str, Optional): one of the values
218
+ returned by get_available_resolutions(), a label such as
219
+ "1080p", or None for the highest resolution available.
220
+ directory_path (str, Optional): the folder to be downloaded to.
221
+
222
+ Returns:
223
+ str: the path of the downloaded file.
224
+
225
+ Raises:
226
+ ResolutionUnavailable: the requested label is not offered.
227
+ """
228
+ if not isinstance(resolution, VideoResolution):
229
+ resolution = self.pick_resolution(
230
+ self.get_available_resolutions(video_info=video_info), resolution
231
+ )
232
+
233
+ url = video_info.video_link
234
+ download_path = join(
235
+ self._download_folder(directory_path),
236
+ f"{self._filename(video_info)}.%(ext)s",
237
+ )
238
+
239
+ # video-only formats (common at higher resolutions) need audio merged in
240
+ format_selector = (
241
+ f"{resolution.format_id}+bestaudio/best"
242
+ if resolution.is_video_only
243
+ else resolution.format_id
244
+ )
245
+
246
+ self.clients.ytdlp.options = {
247
+ "outtmpl": download_path,
248
+ "format": format_selector,
249
+ "merge_output_format": "mp4",
250
+ "postprocessors": [
251
+ {"key": "FFmpegVideoConvertor", "preferedformat": "mp4"}
252
+ ],
253
+ }
254
+
255
+ try:
256
+ info = self.clients.ytdlp.client.extract_info(url, download=True)
257
+ finally:
258
+ self.clients.ytdlp.reset_options()
259
+
260
+ return info["requested_downloads"][-1]["filepath"]