18 | Edit the [.env](./.env) file and set the credentials.
19 | Set the *SPOTIPY_CLIENT_ID* AND *SPOTIPY_CLIENT_SECRET* values.
20 |
21 |
22 | #### 3. Clone the project
23 | ```cmd
24 | git clone https://github.com/piyx/YoutubeSpotifyDL.git
25 | ```
26 |
27 | #### 4. Navigate to the project directory.
28 | ```cmd
29 | cd YoutubeSpotifyDL
30 | ```
31 |
32 | #### 5. Install Dependencies
33 | ```cmd
34 | pip install -r requirements.txt
35 | ```
36 |
37 | #### 6. Install the package locally.
38 | ```cmd
39 | pip install -e .
40 | ```
41 |
42 | #### 7. Run main.py inside ytspdl folder
43 | ```cmd
44 | python ytspdl/main.py
45 | ```
46 |
--------------------------------------------------------------------------------
/SETUP.md:
--------------------------------------------------------------------------------
1 | ## Setup
2 |
3 | 1.Create an app: https://developer.spotify.com/dashboard/applications
4 |
5 | 
6 |
7 |
8 | 2.Copy the Client id and client secret and paste the values in [.env file](./.env) (as mentioned in Step 2)
9 |
10 | 
11 |
12 | 3.Set redirect uri to http://localhost:8888/callback
13 |
14 | 
15 |
--------------------------------------------------------------------------------
/docs/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyx/YoutubeSpotifyDL/d9a2356dff8cae597d5c938594d9d59842687d11/docs/.gitkeep
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | spotipy==2.19.0
2 | ytmusicapi==0.21.0
3 | python-dotenv==0.20.0
4 | yt-dlp==2022.4.8
5 | requests==2.27.1
6 | aiohttp==3.8.1
7 | mutagen==1.45.1
8 | yt-dlp==2022.4.8
9 | inquirerpy==0.3.3
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | if __name__=="__main__":
4 | setup()
--------------------------------------------------------------------------------
/tests/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyx/YoutubeSpotifyDL/d9a2356dff8cae597d5c938594d9d59842687d11/tests/.gitkeep
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from ytspdl.utils import extract_playlist_id
2 | from ytspdl.utils import fetch_youtube_video_id
3 |
4 |
5 | def test_extract_playlist_id():
6 | playlist_url1 = "https://youtube.com/playlist?list=PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_"
7 | playlist_url2 = "https://youtube.com/playlist?somethingrandom"
8 |
9 | assert extract_playlist_id(playlist_url1) == "PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_"
10 | assert extract_playlist_id(playlist_url2) is None
11 |
12 |
13 | def test_fetch_youtube_video_url():
14 | song_name1 = "flor hold on"
15 | song_name2 = ""
16 |
17 | assert fetch_youtube_video_id(song_name1) is not None
18 | assert fetch_youtube_video_id(song_name2) is not None
19 |
20 |
21 | if __name__=="__main__":
22 | test_extract_playlist_id()
23 | test_fetch_youtube_video_url()
--------------------------------------------------------------------------------
/ytspdl/inspect/README.md:
--------------------------------------------------------------------------------
1 | # RESPONSE STRUCTURE
2 |
3 | ## SPOTIFY
4 |
5 | ### PLAYLIST RESPONSE
6 |
7 | ```json
8 | {
9 | "items": [
10 | {
11 | "track": {
12 | "album": {
13 | "name": "album_name"
14 | "images": [
15 | {"url": "song_thumbnail_url"}
16 | ],
17 | },
18 | "artists": [
19 | {"name": "artist_name"}
20 | ],
21 | "name": "song_name"
22 | }
23 | }
24 | ]
25 | }
26 | ```
27 |
28 | ### LIKED SONGS RESPONSE
29 |
30 | ```json
31 | {
32 | "items": [
33 | {
34 | "track": {
35 | "album": {
36 | "name": "album_name"
37 | "images": [
38 | {"url": "song_thumbnail_url"}
39 | ],
40 | },
41 | "artists": [
42 | {"name": "artist_name"}
43 | ],
44 | "name": "song_name"
45 | }
46 | }
47 | ]
48 | }
49 | ```
50 |
51 | ### INDIVIDUAL SONG RESPONSE
52 |
53 | ```json
54 | {
55 | "tracks": {
56 | "items": [
57 | {
58 | "album": {
59 | "name": "album_name"
60 | "images": [
61 | {"url": "song_thumbnail_url"}
62 | ],
63 | },
64 | "artists": [
65 | {"name": "artist_name"}
66 | ],
67 | "name": "song_name"
68 | }
69 | ]
70 | }
71 | }
72 | ```
73 |
74 |
75 | ## YOUTUBE
76 |
77 | ### PLAYLIST SONG RESPONSE
78 | ```json
79 | {
80 | "tracks": [
81 | {
82 | "title": "song_name",
83 | "album": {
84 | "name": "album_name"
85 | },
86 | "artists": [
87 | {
88 | "name": "artist_name"
89 | }
90 | ],
91 | "thumbnails": [
92 | {
93 | "url": "song_thumbnail_url"
94 | }
95 | ]
96 | }
97 | ]
98 | }
99 | ```
100 |
101 | ### INDIVIDUAL SONG RESPONSE
102 | ```json
103 | [
104 | {
105 | "videoId": "youtube_song_video_id",
106 | "title": "song_name",
107 | "album": {
108 | "name": "album_name"
109 | },
110 | "artists": [
111 | {
112 | "name": "artist_name"
113 | }
114 | ],
115 | "thumbnails": [
116 | {
117 | "url": "song_thumbnail_url"
118 | }
119 | ]
120 | }
121 | ]
122 | ```
--------------------------------------------------------------------------------
/ytspdl/inspect/generate_and_inspect.py:
--------------------------------------------------------------------------------
1 | """
2 | Fetch data from library/api and store it in json files and
3 | inspect the results in json files for the data structure.
4 | """
5 |
6 | import json
7 | import os
8 |
9 | import dotenv
10 | import spotipy
11 | import ytmusicapi
12 | from spotipy.oauth2 import SpotifyOAuth
13 |
14 | from ytspdl.models import MusicServiceData
15 | from ytspdl.utils import extract_playlist_id
16 |
17 |
18 | # Load environment variables
19 | dotenv.load_dotenv(".env")
20 |
21 |
22 | # Sample album and playlist url
23 | spotify_json_data_folder = "spotify-json-data"
24 | sample_spotify_playlist_url = "https://open.spotify.com/playlist/37i9dQZF1E8UXBoz02kGID"
25 | sample_spotify_song_query = "flor hold on"
26 |
27 | youtube_json_data_folder = "youtube-json-data"
28 | sample_youtube_playlist_url = "https://youtube.com/playlist?list=PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_"
29 | sample_youtube_song_query = "flor hold on"
30 |
31 |
32 | def generate_spotify_data() -> MusicServiceData:
33 | spotify_oauth = SpotifyOAuth(scope=os.getenv("SPOTIPY_SCOPE"))
34 | spotify = spotipy.Spotify(oauth_manager=spotify_oauth)
35 |
36 | return MusicServiceData(
37 | liked_songs=spotify.current_user_saved_tracks(),
38 | playlist_songs=spotify.playlist(sample_spotify_playlist_url),
39 | individual_song=spotify.search(sample_spotify_song_query),
40 | )
41 |
42 |
43 | def generate_youtube_data() -> MusicServiceData:
44 | youtube = ytmusicapi.YTMusic()
45 | playlist_id = extract_playlist_id(sample_youtube_playlist_url)
46 |
47 | return MusicServiceData(
48 | liked_songs=[],
49 | playlist_songs=youtube.get_playlist(playlist_id),
50 | individual_song=youtube.search(sample_youtube_song_query),
51 | )
52 |
53 |
54 | def write_to_json(directory: str, data_source: callable) -> None:
55 | liked_songs, playlist_songs, individual_song = data_source()
56 | os.makedirs(directory, exist_ok=True)
57 |
58 | with (
59 | open(f"./{directory}/liked_songs.json", "w") as liked_songs_json,
60 | open(f"./{directory}/playlist_songs.json", "w") as playlist_songs_json,
61 | open(f"./{directory}/individual_song.json", "w") as individual_song_json,
62 | ):
63 | json.dump(liked_songs, liked_songs_json)
64 | json.dump(playlist_songs, playlist_songs_json)
65 | json.dump(individual_song, individual_song_json)
66 |
67 |
68 | if __name__ == "__main__":
69 | write_to_json(directory=spotify_json_data_folder, data_source=generate_spotify_data)
70 | write_to_json(directory=youtube_json_data_folder, data_source=generate_youtube_data)
--------------------------------------------------------------------------------
/ytspdl/main.py:
--------------------------------------------------------------------------------
1 | from threading import Thread
2 | import os
3 |
4 | import InquirerPy
5 | import spotipy
6 | from spotipy.oauth2 import SpotifyOAuth
7 | from ytmusicapi import YTMusic
8 | import dotenv
9 |
10 | from ytspdl.services import Youtube
11 | from ytspdl.services import Spotify
12 | from ytspdl.questions import questions
13 | from ytspdl.models import ResultType
14 | from ytspdl.models import ServiceType
15 | from ytspdl.utils import SongDownloader
16 |
17 |
18 | # Load environment variables
19 | dotenv.load_dotenv(".env")
20 |
21 |
22 | def main():
23 | user_inputs = InquirerPy.prompt(questions=questions)
24 | music_service = None
25 |
26 | match user_inputs["music_service"]:
27 | case ServiceType.SPOTIFY:
28 | client = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=os.getenv("SPOTIPY_SCOPE")))
29 | music_service = Spotify(client=client)
30 | case ServiceType.YOUTUBE:
31 | client = YTMusic()
32 | music_service = Youtube(client=client)
33 |
34 | match user_inputs["download_choice"]:
35 | case ResultType.PLAYLIST:
36 | playlist_url = user_inputs["playlist_url"]
37 | limit = int(user_inputs["limit"]) if user_inputs["limit"] else None
38 | songs = music_service.get_playlist_songs(playlist_url=playlist_url, limit=limit)
39 | case ResultType.LIKED:
40 | limit = int(user_inputs["limit"]) if user_inputs["limit"] else None
41 | songs = music_service.get_liked_songs(limit=limit)
42 | case ResultType.INDIVIDUAL:
43 | song_name = user_inputs["song_name"]
44 | songs = music_service.get_song(song_name=song_name)
45 |
46 |
47 | threads = []
48 | download_location = user_inputs["download_location"]
49 |
50 | print("Press ctrl+c to stop.")
51 | for song in songs:
52 | song_downloader = SongDownloader(song=song, download_location=download_location)
53 | thread = Thread(target=song_downloader.download, daemon=True)
54 | threads.append(thread)
55 | thread.start()
56 |
57 | for thread in threads:
58 | thread.join()
59 |
60 |
61 | if __name__=="__main__":
62 | main()
--------------------------------------------------------------------------------
/ytspdl/models/__init__.py:
--------------------------------------------------------------------------------
1 | from ytspdl.models.servicedata import MusicServiceData
2 | from ytspdl.models.song import Song
3 | from ytspdl.models.resulttype import ResultType
4 | from ytspdl.models.servicetype import ServiceType
--------------------------------------------------------------------------------
/ytspdl/models/resulttype.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 |
4 | class ResultType(enum.Enum):
5 | PLAYLIST = 1
6 | LIKED = 2
7 | INDIVIDUAL = 3
--------------------------------------------------------------------------------
/ytspdl/models/servicedata.py:
--------------------------------------------------------------------------------
1 | from typing import NamedTuple
2 |
3 |
4 | JsonType = list | dict
5 |
6 |
7 | class MusicServiceData(NamedTuple):
8 | """Data class for music service
9 | """
10 | liked_songs: JsonType
11 | playlist_songs: JsonType
12 | individual_song: JsonType
--------------------------------------------------------------------------------
/ytspdl/models/servicetype.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 |
4 | class ServiceType(enum.Enum):
5 | SPOTIFY = 1
6 | YOUTUBE = 2
--------------------------------------------------------------------------------
/ytspdl/models/song.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 |
4 | @dataclass
5 | class Song:
6 | """Song entity
7 |
8 | Attributes:
9 | title: The name of the song
10 | artist: The artist of the song
11 | album: The album of the song
12 | imgurl: The image/thumbnail url for the song
13 | vidurl: The youtube video url for the song
14 | """
15 | title: str = None
16 | artist: str = None
17 | album: str = None
18 | imgurl: str = None
19 | vidurl: str = None
--------------------------------------------------------------------------------
/ytspdl/questions.py:
--------------------------------------------------------------------------------
1 | from InquirerPy.validator import EmptyInputValidator
2 | from InquirerPy.validator import PathValidator
3 | from InquirerPy.base.control import Choice
4 |
5 | from ytspdl.models import ResultType
6 | from ytspdl.models import ServiceType
7 |
8 | questions = [
9 | {
10 | "name": "music_service",
11 | "message": "Download From?",
12 | "type": "list",
13 | "choices": [
14 | Choice(name="Spotify", value=ServiceType.SPOTIFY),
15 | Choice(name="Youtube", value=ServiceType.YOUTUBE),
16 | ]
17 | },
18 | {
19 | "name": "download_choice",
20 | "message": "What do you want to do?",
21 | "type": "list",
22 | "choices": [
23 | Choice(name="Download a playlist", value=ResultType.PLAYLIST),
24 | Choice(name="Download liked songs", value=ResultType.LIKED),
25 | Choice(name="Download a particular song", value=ResultType.INDIVIDUAL),
26 | ]
27 | },
28 | {
29 | "name": "playlist_url",
30 | "type": "input",
31 | "message": "Enter playlist url",
32 | "validate": EmptyInputValidator("Playlist url cannot be empty"),
33 | "when": lambda x: x["download_choice"] == ResultType.PLAYLIST
34 | },
35 | {
36 | "name": "song_name",
37 | "type": "input",
38 | "message": "Enter song name (Title and Artist):",
39 | "validate": EmptyInputValidator("Song name cannot be empty"),
40 | "when": lambda x: x["download_choice"] == ResultType.INDIVIDUAL
41 | },
42 | {
43 | "name": "limit",
44 | "message": "How many songs do you want to download? (Leave blank to download all):",
45 | "type": "number",
46 | "default": None,
47 | "min_allowed": 1,
48 | "when": lambda x: x["download_choice"] in [ResultType.PLAYLIST, ResultType.LIKED]
49 | },
50 | {
51 | "name": "download_location",
52 | "type": "filepath",
53 | "message": "Enter path where you want songs to be downloaded:",
54 | "only_directories": True,
55 | "validate": PathValidator()
56 | }
57 | ]
--------------------------------------------------------------------------------
/ytspdl/services/__init__.py:
--------------------------------------------------------------------------------
1 | from ytspdl.services.spotify import Spotify
2 | from ytspdl.services.ytmusic import Youtube
3 | from ytspdl.services.musicservice import MusicService
--------------------------------------------------------------------------------
/ytspdl/services/musicservice.py:
--------------------------------------------------------------------------------
1 | from abc import abstractmethod
2 | from abc import ABC
3 |
4 |
5 | from ytspdl.models import Song
6 |
7 |
8 | class MusicService(ABC):
9 | @abstractmethod
10 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]:
11 | ...
12 |
13 | @abstractmethod
14 | def get_liked_songs(self, limit: int = None) -> list[Song]:
15 | ...
16 |
17 | @abstractmethod
18 | def get_song(self, song_name: str) -> Song:
19 | ...
20 |
--------------------------------------------------------------------------------
/ytspdl/services/spotify.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | import functools
3 |
4 | import spotipy
5 |
6 | from ytspdl.services.musicservice import MusicService
7 | from ytspdl.models import Song
8 | from ytspdl.models import ResultType
9 |
10 |
11 | @dataclass
12 | class Spotify(MusicService):
13 | client: spotipy.Spotify
14 |
15 | def _parse_track(self, track: dict) -> Song:
16 | return Song(
17 | title=track["name"],
18 | album=track["album"]["name"],
19 | artist=track["artists"][0]["name"],
20 | imgurl=track["album"]["images"][0]["url"]
21 | )
22 |
23 | def _parse_songs(self, results: dict, result_type: ResultType) -> list[Song]:
24 | # Spotify has inconsitent response structure
25 | match result_type:
26 | case ResultType.PLAYLIST | ResultType.LIKED:
27 | results = [item["track"] for item in results["items"]]
28 | case ResultType.INDIVIDUAL:
29 | results = results["tracks"]["items"]
30 |
31 | return [
32 | self._parse_track(track)
33 | for track in results
34 | if track is not None
35 | ]
36 |
37 | def _fetch_songs(self, api: callable, result_type: ResultType, limit: int = None) -> list[Song]:
38 | if limit is None:
39 | limit = 1000
40 |
41 | offset = 0
42 | songs_fetched = 0
43 | songs = []
44 | fetched_all = False
45 |
46 | while not fetched_all and songs_fetched < limit:
47 | songs_to_fetch = min(limit-songs_fetched, 50)
48 | results = api(offset=offset, limit=songs_to_fetch)
49 |
50 | next_songs = self._parse_songs(results=results, result_type=result_type)
51 | songs.extend(next_songs)
52 |
53 | songs_fetched += len(next_songs)
54 | offset += len(next_songs)
55 |
56 | if not next_songs:
57 | fetched_all = True
58 |
59 | return songs
60 |
61 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]:
62 | api_source = functools.partial(self.client.playlist_items, playlist_id=playlist_url)
63 | return self._fetch_songs(api=api_source, result_type=ResultType.PLAYLIST, limit=limit)
64 |
65 | def get_liked_songs(self, limit: int = None) -> list[Song]:
66 | api_source = self.client.current_user_saved_tracks
67 | return self._fetch_songs(api=api_source, result_type=ResultType.LIKED, limit=limit)
68 |
69 | def get_song(self, song_name: str) -> list[Song]:
70 | api_source = functools.partial(self.client.search, q=song_name)
71 | return self._fetch_songs(api=api_source, result_type=ResultType.INDIVIDUAL, limit=1)
--------------------------------------------------------------------------------
/ytspdl/services/ytmusic.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 | import functools
3 |
4 | from ytmusicapi import YTMusic
5 |
6 | from ytspdl.services.musicservice import MusicService
7 | from ytspdl.models import Song
8 | from ytspdl.models import ResultType
9 | from ytspdl.utils import extract_playlist_id
10 |
11 |
12 | @dataclass
13 | class Youtube(MusicService):
14 | client: YTMusic
15 |
16 | def _parse_track(self, track: dict) -> Song:
17 | return Song(
18 | title=track["title"],
19 | album=track["album"]["name"] if track["album"] else None,
20 | artist=track["artists"][0]["name"],
21 | imgurl=f'https://i.ytimg.com/vi/{track["videoId"]}/sddefault.jpg'
22 | )
23 |
24 | def _parse_songs(self, results: dict, limit: int, result_type: ResultType) -> list[Song]:
25 | match result_type:
26 | case ResultType.PLAYLIST:
27 | results = results["tracks"]
28 | case ResultType.INDIVIDUAL:
29 | results = results
30 | case ResultType.LIKED:
31 | pass
32 |
33 | return [
34 | self._parse_track(track)
35 | for count, track in enumerate(results, 1)
36 | if count <= limit
37 | ]
38 |
39 | def _fetch_songs(self, api: callable, result_type: ResultType, limit: int = None) -> list[Song]:
40 | if limit is None:
41 | limit = 1000
42 |
43 | results = api(limit=limit)
44 | return self._parse_songs(results=results, limit=limit, result_type=result_type)
45 |
46 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]:
47 | playlist_id = extract_playlist_id(playlist_url=playlist_url)
48 | api_source = functools.partial(self.client.get_playlist, playlistId=playlist_id)
49 | return self._fetch_songs(api=api_source, result_type=ResultType.PLAYLIST, limit=limit)
50 |
51 | def get_liked_songs(self, limit: int = None) -> list[Song]:
52 | raise NotImplementedError("Requires youtube authentication. Not Implemented yet")
53 |
54 | def get_song(self, song_name: str) -> Song:
55 | results = self.client.search(query=song_name)
56 | return self._parse_songs(results=results, limit=1, result_type=ResultType.INDIVIDUAL)
--------------------------------------------------------------------------------
/ytspdl/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from ytspdl.utils.helpers import extract_playlist_id
2 | from ytspdl.utils.helpers import fetch_youtube_video_id
3 | from ytspdl.utils.metadata import add_tags_to_song
4 | from ytspdl.utils.helpers import get_sanitized_song_name
5 | from ytspdl.utils.helpers import download_song_from_youtube
6 | from ytspdl.utils.downloader import SongDownloader
--------------------------------------------------------------------------------
/ytspdl/utils/downloader.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from ytspdl.models import Song
4 | from ytspdl.utils import fetch_youtube_video_id
5 | from ytspdl.utils import add_tags_to_song
6 | from ytspdl.utils import get_sanitized_song_name
7 | from ytspdl.utils import download_song_from_youtube
8 |
9 |
10 | class SongDownloader:
11 | def __init__(self, song: Song, download_location: str = "./"):
12 | self.song = song
13 | self.download_location = download_location
14 | self.song_name = get_sanitized_song_name(song=song)
15 | self.song_path = f"{self.download_location}/{self.song_name}.m4a"
16 |
17 | if self.song.vidurl is None:
18 | video_id = fetch_youtube_video_id(song_name=self.song_name)
19 | self.song.vidurl = f"https://www.youtube.com/watch?v={video_id}"
20 |
21 | def download(self):
22 | print(f"Downloading {self.song_name}...")
23 | if os.path.exists(self.song_path):
24 | print(f"Skipping {self.song_name}: Song Already Exists!")
25 | return
26 |
27 | try:
28 | download_song_from_youtube(video_url=self.song.vidurl, song_path=self.song_path)
29 | add_tags_to_song(song_path=self.song_path, song=self.song)
30 | except Exception as e:
31 | print(f"Error downloading song {self.song_name}, Error Message: {e.with_traceback()}")
32 | if os.path.exists(self.song_path):
33 | os.remove(self.song_path)
--------------------------------------------------------------------------------
/ytspdl/utils/helpers.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import requests
4 | import aiohttp
5 | import yt_dlp
6 |
7 | from ytspdl.models import Song
8 |
9 |
10 | def extract_playlist_id(playlist_url: str) -> str | None:
11 | '''Extract playlist id from youtube playlist url'''
12 | match = re.match('.*list=(.*)', playlist_url)
13 | return match and match.group(1)
14 |
15 |
16 | async def fetch_youtube_video_id_async(song_name: str) -> str | None:
17 | '''Fetch youtube video id for a given song name asynchronously'''
18 |
19 | # Replacing whitespace with '+' symbol, since search query cannot have whitespace
20 | query = "+".join(song_name.split()).encode("utf-8")
21 | url = f"https://www.youtube.com/results?search_query={query}"
22 |
23 | async with aiohttp.ClientSession() as session:
24 | async with session.get(url) as response:
25 | html = await response.text()
26 | vid_ids = re.findall(r"watch\?v=(\S{11})", html)
27 | return vid_ids[0] if vid_ids else None
28 |
29 |
30 | def fetch_youtube_video_id(song_name: str) -> str | None:
31 | '''Fetch youtube video id for a given song name'''
32 |
33 | # Replacing whitespace with '+' symbol, since search query cannot have whitespace
34 | query = "+".join(song_name.split()).encode("utf-8")
35 | url = f"https://www.youtube.com/results?search_query={query}"
36 | html = requests.get(url)
37 |
38 | # Search for all video ids in the html page
39 | video_ids = re.findall(r"watch\?v=(\S{11})", html.text)
40 | return video_ids[0] if video_ids else None
41 |
42 |
43 | def get_sanitized_song_name(song: Song) -> str:
44 | '''Remove all invalid characters from song name'''
45 | INVALID_CHARACTERS = r"[#<%>&\*\{\?\}/\\$+!`'\|\"=@\.\[\]:]*"
46 | song_name = re.sub(INVALID_CHARACTERS, "", f"{song.artist} {song.title}")
47 | return song_name
48 |
49 |
50 | def download_song_from_youtube(video_url: str, song_path: str) -> None:
51 | '''Download only audio (m4a) from youtube for the given video url'''
52 | ydl_opts = {
53 | "format": "m4a/bestaudio/best",
54 | "outtmpl": song_path,
55 | "quiet": True,
56 | "no_warnings": True,
57 | "newline": True
58 | }
59 |
60 | with yt_dlp.YoutubeDL(ydl_opts) as ydl:
61 | ydl.download([video_url])
--------------------------------------------------------------------------------
/ytspdl/utils/metadata.py:
--------------------------------------------------------------------------------
1 | from mutagen.mp4 import MP4, MP4Cover
2 | import requests
3 |
4 | from ytspdl.models.song import Song
5 |
6 |
7 | def add_tags_to_song(song_path: str, song: Song) -> None:
8 | TITLE = "\xa9nam"
9 | ALBUM = "\xa9alb"
10 | ARTIST = "\xa9ART"
11 | ART = "covr"
12 | f = MP4(song_path)
13 | f[TITLE] = song.title
14 | if song.album is not None:
15 | f[ALBUM] = song.album
16 | f[ARTIST] = song.artist
17 | image_response = requests.get(song.imgurl)
18 | f[ART] = [MP4Cover(image_response.content, MP4Cover.FORMAT_JPEG)]
19 | f.save()
--------------------------------------------------------------------------------