├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── docs ├── youtube2audio.mp4 └── youtube2audio.png ├── img └── default_artwork.png ├── main.py ├── requirements.txt ├── tests ├── test_qt_defaults.py └── test_utils.py ├── ui ├── __init__.py ├── yt2mp3.py └── yt2mp3.ui └── utils ├── __init__.py ├── _threading.py ├── download_youtube.py ├── query_itunes.py ├── query_youtube.py └── timeout.py /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | schedule: 7 | # Schedule for the workflow to run at 00:00 every Sunday 8 | - cron: '0 0 * * 0' 9 | 10 | jobs: 11 | test: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 17 | steps: 18 | - uses: actions/checkout@v2 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Cache Python dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-py-${{ matrix.python-version }}-${{ hashFiles('**/requirements.txt') }} 30 | restore-keys: | 31 | ${{ runner.os }}-py-${{ matrix.python-version }}- 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install -r requirements.txt 37 | 38 | - name: Test with pytest 39 | run: python -W ignore -m unittest tests/test_utils.py 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VSCode interpreter environment 132 | .vscode/ 133 | 134 | # Desktop Service Store 135 | .DS_Store/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ira Horecka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | black: ## Black format every python file to line length 120 2 | find . -type f -name "*.py" | xargs black --line-length=120; 3 | find . -type f -name "*.py" | xargs absolufy-imports; 4 | make clean; 5 | 6 | flake8: ## Flake8 every python file 7 | find . -type f -name "*.py" -a | xargs flake8; 8 | 9 | pylint: ## Pylint every python file 10 | find . -type f -name "*.py" -a | xargs pylint; 11 | 12 | clean: ## Remove pycache 13 | find . -type d -name "__pycache__" | xargs rm -r; 14 | find . -type f -name ".DS_Store" | xargs rm; 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **youtube2audio** 2 | 3 | ![Version](https://img.shields.io/badge/version-v2024.09.26-orange) 4 | [![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/) 5 | [![Licence](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/irahorecka/youtube2audio/main/LICENSE) 6 | 7 | A desktop GUI application to download YouTube videos as annotated MP3 or MP4 files. 8 |
9 |
10 | ![Application Interface](docs/youtube2audio.png) 11 |
12 | 13 | ## Using the application 14 | 15 | Paste a YouTube playlist or video URL and load its content. Make edits to the table and click "Ask butler" (not shown in image above) to provide annotation hints to your videos. You can choose to download MP3 or MP4 files. Finally, select a download folder, download your videos, and just like that, you have nicely annotated audio files. 16 | 17 | This application uses PyQt5 to provide the user interface and multithreading to execute calls asynchronously. The backend uses ```itunespy``` to suggest song annotations (i.e. "Ask butler"), ```pytube``` to download the YouTube video as an MP4 audio file, and FFmpeg to convert the MP4 file to MP3. 18 | 19 | Watch the video demo for more information. 20 |
21 | 22 | ## Running the application 23 | 24 | 1) Clone GitHub repository 25 | 2) ```pip install -r requirements.txt --upgrade``` 26 | 3) ```python main.py``` 27 | 28 | Check Troubleshooting if you encounter any trouble running / using the application or downloading MP3 files. If undocumented exceptions occur, please file the issue in issues. 29 |
30 | 31 | ## Troubleshooting 32 | 33 | If the script completes instantly without downloading your video(s), you're probably experiencing an ```SSL: CERTIFICATE_VERIFY_FAIL``` exception. This fails to instantiate ```pytube.Youtube```, thus failing the download prematurely. 34 | 35 | To troubleshoot this (if you're using macOS), go to Macintosh HD > Applications > Python3.7 folder (or whatever version of python you're using) > double click on ```Install Certificates.command``` file. This should do the trick. 36 | -------------------------------------------------------------------------------- /docs/youtube2audio.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irahorecka/youtube2audio/9c56256047f29fe20f59a79971f18b9d346a4fb5/docs/youtube2audio.mp4 -------------------------------------------------------------------------------- /docs/youtube2audio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irahorecka/youtube2audio/9c56256047f29fe20f59a79971f18b9d346a4fb5/docs/youtube2audio.png -------------------------------------------------------------------------------- /img/default_artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/irahorecka/youtube2audio/9c56256047f29fe20f59a79971f18b9d346a4fb5/img/default_artwork.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os 3 | import shutil 4 | import sys 5 | import time 6 | 7 | import qdarkstyle 8 | import requests 9 | from PyQt5.QtCore import QPersistentModelIndex, Qt, QThread, QUrl, pyqtSignal 10 | from PyQt5.QtGui import QDesktopServices, QImage, QPixmap 11 | from PyQt5.QtWidgets import ( 12 | QAbstractItemView, 13 | QApplication, 14 | QFileDialog, 15 | QMainWindow, 16 | QTableWidgetItem, 17 | ) 18 | 19 | import utils 20 | from ui import UiMainWindow 21 | 22 | 23 | BASE_PATH = os.path.dirname(os.path.abspath(__file__)) 24 | IMG_PATH = os.path.join(BASE_PATH, "img") 25 | UTILS_PATH = os.path.join(BASE_PATH, "utils") 26 | 27 | 28 | class MainPage(QMainWindow, UiMainWindow): 29 | """Main page of the application.""" 30 | 31 | def __init__(self, parent=None): 32 | super(MainPage, self).__init__(parent) 33 | self.setupUi(self) 34 | # Hide the fetching, reattempt, error label, and revert button 35 | self.url_fetching_data_label.hide() 36 | self.url_error_label.hide() 37 | self.url_reattempt_load_label.hide() 38 | self.url_poor_connection.hide() 39 | self.revert_annotate.hide() 40 | # Activate hyperlink on upper right 41 | self.credit_url.linkActivated.connect(self.set_credit_url) 42 | self.credit_url.setText('source code') 43 | # Connect the delete video button with the remove_selected_items fn. 44 | self.remove_from_table_button.clicked.connect(self.remove_selected_items) 45 | # Buttons connection with the appropriate functions 46 | self.save_as_mp3_box.setChecked(True) 47 | self.save_as_mp3_box.clicked.connect(self.set_check_mp3_box) 48 | self.save_as_mp4_box.clicked.connect(self.set_check_mp4_box) 49 | self.url_load_button.clicked.connect(self.url_loading_button_click) 50 | self.url_input.returnPressed.connect(self.url_load_button.click) 51 | self.url_input.mousePressEvent = lambda _: self.url_input.selectAll() 52 | self.download_button.clicked.connect(self.download_button_click) 53 | self.download_path.clicked.connect(self.get_download_path) 54 | self.itunes_annotate.clicked.connect(self.itunes_annotate_click) 55 | self.revert_annotate.clicked.connect(self.default_annotate_table) 56 | self.video_table.cellPressed.connect(self.load_table_content) 57 | # edit table cell with single click 58 | self.video_table.setEditTriggers(QAbstractItemView.CurrentChanged) 59 | # Input changes in video property text box to appropriate cell. 60 | self.change_video_info_input.clicked.connect(self.replace_single_cell) 61 | self.change_video_info_input_all.clicked.connect(self.replace_all_cells) 62 | self.video_info_input.returnPressed.connect(self.change_video_info_input.click) 63 | # Exit application 64 | self.cancel_button.clicked.connect(self.close) 65 | # Get download directory 66 | self.download_dir = BASE_PATH 67 | self.download_folder_select.setText(self._get_parent_current_dir(self.download_dir)) # get directory tail 68 | 69 | def url_loading_button_click(self): 70 | """Reads input data from self.url_input and creates an instance 71 | of the UrlLoading thread.""" 72 | # declare videos_dict upon loading url 73 | self.videos_dict = {} 74 | playlist_url = self._get_cell_text(self.url_input) 75 | 76 | self._reflect_url_loading_status() 77 | self.url_fetching_data_label.show() 78 | self.url_load = UrlLoading(playlist_url) 79 | self.url_load.loadStatus.connect(self._reflect_url_loading_status) 80 | self.url_load.countChanged.connect(self._url_loading_finished) 81 | self.url_load.start() 82 | 83 | def _reflect_url_loading_status(self, status=None): 84 | """Reflect YouTube url loading status. If no status is provided, 85 | hide all error label and keep table content.""" 86 | self.video_table.clearContents() # clear table content when loading 87 | self.video_info_input.setText("") # clear video info input cell 88 | self._display_artwork(None) # clear artwork display to default image 89 | self.url_poor_connection.hide() 90 | self.url_fetching_data_label.hide() 91 | self.url_reattempt_load_label.hide() 92 | self.url_error_label.hide() 93 | if status == "success": 94 | return 95 | # if status obj is not null, but not "success" 96 | if status: 97 | if status == "invalid url": 98 | self.url_error_label.show() 99 | elif status == "reattempt": 100 | self.url_reattempt_load_label.show() 101 | elif status == "server error": 102 | self.url_poor_connection.show() 103 | self.revert_annotate.hide() 104 | self.itunes_annotate.show() # refresh "Ask butler" button 105 | 106 | def _url_loading_finished(self, videos_dict, is_executed): 107 | """Retrieves data from thread when complete, updates GUI table.""" 108 | # First entry of self.videos_dict in MainPage class 109 | self.videos_dict = videos_dict 110 | self.video_table.clearContents() # clear table for new loaded content 111 | if is_executed: 112 | self.default_annotate_table() # set table content 113 | else: 114 | self.url_error_label.show() 115 | 116 | def itunes_annotate_click(self): 117 | """Load iTunes annotation info on different thread.""" 118 | self.video_info_input.setText("") 119 | # i.e. clicked annotate button with empty table 120 | if not self._assert_videos_dict(self.video_info_input, "Could not get information."): 121 | return 122 | 123 | self.annotate = iTunesLoading(self.videos_dict) 124 | self.annotate.loadFinished.connect(self._itunes_annotate_finished) 125 | self.annotate.start() 126 | 127 | def _itunes_annotate_finished(self, itunes_query_tuple, query_status): 128 | """Populate GUI table with iTunes meta information once 129 | iTunes annotation query complete.""" 130 | for row_index, ITUNES_META_JSON in itunes_query_tuple: 131 | self._itunes_annotate_table(row_index, ITUNES_META_JSON) 132 | 133 | if not query_status: 134 | # no iTunes metadata available or poor connection 135 | self.video_info_input.setText("Could not get information.") 136 | else: 137 | # show revert button if iTunes annotation loaded successfully 138 | self.itunes_annotate.hide() 139 | self.revert_annotate.show() 140 | 141 | def _itunes_annotate_table(self, row_index, ITUNES_META_JSON): 142 | """Provide iTunes annotation guess based on video title""" 143 | try: 144 | song_name, song_index = ITUNES_META_JSON["track_name"], 0 145 | album_name, album_index = ITUNES_META_JSON["album_name"], 1 146 | artist_name, artist_index = ITUNES_META_JSON["artist_name"], 2 147 | genre_name, genre_index = ITUNES_META_JSON["primary_genre_name"], 3 148 | artwork_name, artwork_index = ITUNES_META_JSON["artwork_url_fullres"], 4 149 | except TypeError: # ITUNES_META_JSON was never called. 150 | # get video title 151 | song_name, song_index = ( 152 | self._get_cell_text(self.video_table.item(row_index, 0)), 153 | 0, 154 | ) 155 | if not song_name: 156 | song_name = "Unknown" 157 | 158 | album_name, album_index = "Unknown", 1 159 | artist_name, artist_index = "Unknown", 2 160 | genre_name, genre_index = "Unknown", 3 161 | artwork_name, artwork_index = "Unknown", 4 162 | 163 | self.video_table.setItem(row_index, song_index, QTableWidgetItem(song_name)) 164 | self.video_table.setItem(row_index, album_index, QTableWidgetItem(album_name)) 165 | self.video_table.setItem(row_index, artist_index, QTableWidgetItem(artist_name)) 166 | self.video_table.setItem(row_index, genre_index, QTableWidgetItem(genre_name)) 167 | self.video_table.setItem(row_index, artwork_index, QTableWidgetItem(artwork_name)) 168 | 169 | def default_annotate_table(self): 170 | """Default table annotation to video title in song columns""" 171 | if not self.videos_dict: # i.e. an invalid playlist input 172 | self.video_table.clearContents() 173 | return 174 | 175 | self.video_info_input.setText("") 176 | 177 | for index, key in enumerate(self.videos_dict): 178 | self.video_table.setItem(index, 0, QTableWidgetItem(key)) # part of QWidget 179 | self.video_table.setItem(index, 1, QTableWidgetItem("Unknown")) 180 | self.video_table.setItem(index, 2, QTableWidgetItem("Unknown")) 181 | self.video_table.setItem(index, 3, QTableWidgetItem("Unknown")) 182 | self.video_table.setItem(index, 4, QTableWidgetItem("Unknown")) 183 | self.revert_annotate.hide() 184 | self.itunes_annotate.show() 185 | 186 | def get_download_path(self): 187 | """Fetch download file path""" 188 | self.download_dir = QFileDialog.getExistingDirectory(self, "Open folder", BASE_PATH) or BASE_PATH 189 | self.download_folder_select.setText(self._get_parent_current_dir(self.download_dir)) 190 | 191 | def download_button_click(self): 192 | """Executes when the button is clicked""" 193 | # assert self.videos_dict exists 194 | if not self._assert_videos_dict(self.download_status, "No video to download."): 195 | return 196 | playlist_properties = self._get_playlist_properties() 197 | self.download_button.setEnabled(False) 198 | self.download_status.setText("Downloading...") 199 | self.down = DownloadingVideos( 200 | self.videos_dict, 201 | self.download_dir, 202 | playlist_properties, 203 | self.save_as_mp4_box.isChecked(), 204 | ) 205 | self.down.downloadCount.connect(self._download_finished) 206 | self.down.start() 207 | 208 | def _get_playlist_properties(self): 209 | """Get video information from self.video_table to reflect to 210 | downloaded MP3 metadata.""" 211 | playlist_properties = [] 212 | for row_index, _ in enumerate(self.videos_dict.items()): 213 | song_properties = {} 214 | song_properties["song"] = self._get_cell_text(self.video_table.item(row_index, 0)).replace( 215 | "/", "-" 216 | ) # will be filename -- change illegal char to legal - make func 217 | song_properties["album"] = self._get_cell_text(self.video_table.item(row_index, 1)) 218 | song_properties["artist"] = self._get_cell_text(self.video_table.item(row_index, 2)) 219 | song_properties["genre"] = self._get_cell_text(self.video_table.item(row_index, 3)) 220 | song_properties["artwork"] = self._get_cell_text(self.video_table.item(row_index, 4)) 221 | 222 | playlist_properties.append(song_properties) # this assumes that dict will be ordered like list 223 | 224 | return playlist_properties 225 | 226 | def _download_finished(self, download_time): 227 | """Emit changes to MainPage once dowload is complete.""" 228 | _min = int(download_time // 60) 229 | sec = int(download_time % 60) 230 | self.download_status.setText(f"Download time: {_min} min. {sec} sec.") 231 | self.download_button.setEnabled(True) 232 | 233 | def load_table_content(self, row, column): 234 | """Display selected cell content into self.video_info_input 235 | and display selected artwork on Qpixmap widget.""" 236 | # display video info in self.video_info_input 237 | self._display_cell_content(row, column) 238 | # load and display video artwork 239 | artwork_file = self._get_cell_text(self.video_table.item(row, 4)) 240 | self.loaded_artwork = ArtworkLoading(artwork_file) # if populated, `artwork_file` is a url 241 | self.loaded_artwork.loadFinished.connect(self._display_artwork) 242 | self.loaded_artwork.start() 243 | 244 | def _display_cell_content(self, row, column): 245 | """Display selected cell content in self.video_info_input""" 246 | self.video_info_input.setText(self._get_cell_text(self.video_table.item(row, column))) 247 | 248 | def _display_artwork(self, artwork_content): 249 | """Display selected artwork on Qpixmap widget.""" 250 | if not artwork_content: 251 | qt_artwork_content = os.path.join(IMG_PATH, "default_artwork.png") 252 | self.album_artwork.setPixmap(QPixmap(qt_artwork_content)) 253 | else: 254 | qt_artwork_content = QImage() 255 | qt_artwork_content.loadFromData(artwork_content) 256 | self.album_artwork.setPixmap(QPixmap.fromImage(qt_artwork_content)) 257 | 258 | self.album_artwork.setScaledContents(True) 259 | self.album_artwork.setAlignment(Qt.AlignCenter) 260 | 261 | def remove_selected_items(self): 262 | """Removes the selected items from self.videos_table and self.videos_dict. 263 | Table widget updates -- multiple row deletion capable.""" 264 | video_list = [] 265 | if self._assert_videos_dict(): 266 | video_list = list(self.videos_dict.items()) 267 | 268 | row_index_list = [] 269 | for model_index in self.video_table.selectionModel().selectedRows(): 270 | row = model_index.row() 271 | row_index = QPersistentModelIndex(model_index) 272 | row_index_list.append(row_index) 273 | with contextlib.suppress(IndexError, KeyError): 274 | current_key = video_list[row][0] 275 | del self.videos_dict[current_key] # remove row item from self.videos_dict 276 | for index in row_index_list: 277 | self.video_table.removeRow(index.row()) 278 | 279 | def replace_single_cell(self): 280 | """Change selected cell value to value in self.video_info_input.""" 281 | row = self.video_table.currentIndex().row() 282 | column = self.video_table.currentIndex().column() 283 | video_info_input_value = self._get_cell_text(self.video_info_input) 284 | self._replace_cell_item(row, column, video_info_input_value) 285 | 286 | def replace_all_cells(self): 287 | """Change all rows, except songs, in table to match selected cell row.""" 288 | # get row of cells to replace all others 289 | replacement_row_index = self.video_table.currentIndex().row() 290 | 291 | for row_index in range(self.video_table.rowCount()): 292 | # omit first column (i.e. song) 293 | for col_index in range(1, self.video_table.columnCount()): 294 | # get current cell item to be deleted and cell item to replace 295 | current_value = self._get_cell_text(self.video_table.item(row_index, col_index)) 296 | replacement_value = self._get_cell_text(self.video_table.item(replacement_row_index, col_index)) 297 | if current_value and replacement_value: 298 | self._replace_cell_item(row_index, col_index, replacement_value) 299 | 300 | def _replace_cell_item(self, row, column, value): 301 | """Replace cell with value at row / column index.""" 302 | self.video_table.setItem(row, column, QTableWidgetItem(value)) 303 | 304 | def set_check_mp3_box(self): 305 | """if self.save_as_mp3_box is checked, uncheck 306 | self.save_as_mp4_box.""" 307 | self.save_as_mp3_box.setChecked(True) 308 | self.save_as_mp4_box.setChecked(False) 309 | 310 | def set_check_mp4_box(self): 311 | """if self.save_as_mp4_box is checked, uncheck 312 | self.save_as_mp3_box.""" 313 | self.save_as_mp3_box.setChecked(False) 314 | self.save_as_mp4_box.setChecked(True) 315 | 316 | def _assert_videos_dict(self, qline_edit_obj=None, text=""): 317 | """Assert existence of `self.videos_dict` in current program state of caller. 318 | If not, display `text` to `qline_edit_obj` if `qline_edit_obj` provided.""" 319 | try: 320 | assert self.videos_dict 321 | except (AttributeError, AssertionError): 322 | if qline_edit_obj: 323 | qline_edit_obj.setText(text) 324 | return False 325 | return True 326 | 327 | @staticmethod 328 | def set_credit_url(url_str): 329 | """Set source code url on upper right of table.""" 330 | QDesktopServices.openUrl(QUrl(url_str)) 331 | 332 | @staticmethod 333 | def _get_cell_text(cell_item): 334 | """Get text of cell value, if empty return empty str.""" 335 | try: 336 | cell_item = cell_item.text() 337 | return cell_item 338 | except AttributeError: 339 | cell_item = "" 340 | return cell_item 341 | 342 | @staticmethod 343 | def _get_parent_current_dir(current_path): 344 | """Get current and parent directory as str.""" 345 | parent_dir, current_dir = os.path.split(current_path) 346 | parent_dir = os.path.split(parent_dir)[1] # get tail of parent_dir 347 | return f"../{parent_dir}/{current_dir}" 348 | 349 | 350 | class UrlLoading(QThread): 351 | """Load video data from YouTube url.""" 352 | 353 | countChanged = pyqtSignal(dict, bool) 354 | loadStatus = pyqtSignal(str) 355 | 356 | def __init__(self, playlist_link, parent=None): 357 | QThread.__init__(self, parent) 358 | self.playlist_link = playlist_link 359 | self.reattempt_count = 0 360 | self.override_error = False 361 | 362 | def run(self): 363 | """Main function, gets all the playlist videos data, emits the info dict""" 364 | # allow 5 reattempts if error in fetching YouTube videos 365 | # else just get loaded videos by overriding error handling 366 | if self.reattempt_count > 5: 367 | self.override_error = True 368 | 369 | try: 370 | videos_dict = utils.get_youtube_content(self.playlist_link, self.override_error) 371 | if not videos_dict: 372 | # if empty videos_dict returns, throw invalid url warning. 373 | self.loadStatus.emit("invalid url") 374 | else: 375 | self.loadStatus.emit("success") 376 | self.countChanged.emit(videos_dict, True) 377 | 378 | except RuntimeError as error: # handle error from video load fail 379 | error_message = str(error) 380 | if any(message in error_message for message in ["not a valid URL", "Unsupported URL", "list"]): 381 | self.loadStatus.emit("invalid url") 382 | elif "nodename nor servname provided" in error_message: 383 | self.loadStatus.emit("server error") 384 | else: 385 | self.loadStatus.emit("reattempt") 386 | self.reattempt_count += 1 387 | self.run() 388 | 389 | 390 | class iTunesLoading(QThread): 391 | """Get video data properties from iTunes.""" 392 | 393 | loadFinished = pyqtSignal(tuple, bool) 394 | 395 | def __init__(self, videos_dict, parent=None): 396 | QThread.__init__(self, parent) 397 | self.videos_dict = videos_dict 398 | 399 | def run(self): 400 | """Multithread query to iTunes - return tuple.""" 401 | try: 402 | query_iter = ((row_index, key_value) for row_index, key_value in enumerate(self.videos_dict.items())) 403 | except AttributeError: # i.e. no content in table -- exit early 404 | return 405 | itunes_query = utils.map_threads(utils.thread_query_itunes, query_iter) 406 | itunes_query_tuple = tuple(itunes_query) 407 | query_status = bool(self.check_itunes_nonetype(itunes_query_tuple)) 408 | self.loadFinished.emit(itunes_query_tuple, query_status) 409 | 410 | @staticmethod 411 | def check_itunes_nonetype(query_tuple): 412 | """Check if none of the queries were successful.""" 413 | _, itunes_query = tuple(zip(*query_tuple)) 414 | try: 415 | # successful queries return a dict obj, which is unhashable 416 | set(itunes_query) 417 | return False 418 | except TypeError: 419 | return True 420 | 421 | 422 | class ArtworkLoading(QThread): 423 | """Load artwork bytecode for display on GUI.""" 424 | 425 | loadFinished = pyqtSignal(bytes) 426 | 427 | def __init__(self, artwork_url, parent=None): 428 | QThread.__init__(self, parent) 429 | self.artwork_url = artwork_url 430 | 431 | def run(self): 432 | artwork_img = bytes() 433 | # get url response - if not url, return empty bytes 434 | try: 435 | response = requests.get(self.artwork_url) 436 | except (requests.exceptions.MissingSchema, requests.exceptions.ConnectionError): 437 | self.loadFinished.emit(artwork_img) 438 | return 439 | 440 | # check validity of url response - if ok return img byte content 441 | if response.status_code != 200: # invalid image url 442 | self.loadFinished.emit(artwork_img) 443 | return 444 | artwork_img = response.content 445 | self.loadFinished.emit(artwork_img) 446 | 447 | 448 | class DownloadingVideos(QThread): 449 | """Download all videos from the videos_dict using the id.""" 450 | 451 | downloadCount = pyqtSignal(float) # attempt to emit delta_t 452 | 453 | def __init__(self, videos_dict, download_path, playlist_properties, save_as_mp4, parent=None): 454 | QThread.__init__(self, parent) 455 | self.videos_dict = videos_dict 456 | self.download_path = download_path 457 | self.playlist_properties = playlist_properties 458 | self.save_as_mp4 = save_as_mp4 459 | 460 | def run(self): 461 | """Main function, downloads videos by their id while emitting progress data""" 462 | # Download 463 | mp4_path = os.path.join(self.download_path, "mp4") 464 | try: 465 | os.mkdir(mp4_path) 466 | except FileExistsError: 467 | pass 468 | except FileNotFoundError: 469 | # If the user downloads to a folder, deletes the folder, and reattempts 470 | # to download to the same folder within the same session. 471 | raise RuntimeError( 472 | f'"{os.path.abspath(os.path.dirname(mp4_path))}" does not exist.\nEnsure this directory exists prior to executing download.' 473 | ) 474 | 475 | time0 = time.time() 476 | video_properties = ( 477 | ( 478 | key_value, 479 | (self.download_path, mp4_path), 480 | self.playlist_properties[index], 481 | self.save_as_mp4, 482 | ) 483 | for index, key_value in enumerate(self.videos_dict.items()) # dict is naturally sorted in iteration 484 | ) 485 | utils.map_threads(utils.thread_query_youtube, video_properties) 486 | shutil.rmtree(mp4_path) # remove mp4 dir 487 | time1 = time.time() 488 | 489 | delta_t = time1 - time0 490 | self.downloadCount.emit(delta_t) 491 | 492 | 493 | if __name__ == "__main__": 494 | app = QApplication(sys.argv) 495 | widget = MainPage() 496 | app.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) 497 | widget.show() 498 | sys.exit(app.exec_()) 499 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | itunespy==1.5.5 2 | moviepy==1.0.3 3 | mutagen 4 | PyQt5 5 | pytube 6 | pytubefix 7 | QDarkStyle 8 | requests 9 | youtube-dl==2020.12.14 10 | -------------------------------------------------------------------------------- /tests/test_qt_defaults.py: -------------------------------------------------------------------------------- 1 | """Test default (static) state of the application upon boot.""" 2 | import os 3 | import sys 4 | import unittest 5 | 6 | from PyQt5.QtWidgets import QApplication 7 | 8 | # get directory to main.py 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 10 | import main 11 | 12 | app = QApplication(sys.argv) 13 | 14 | 15 | class testMain(unittest.TestCase): 16 | """Test the main GUI for default properties.""" 17 | 18 | def setUp(self): 19 | self.form = main.MainPage() 20 | 21 | def test_video_table_defaults(self): 22 | """Test default content of self.video_table -- should be empty""" 23 | row_count = 250 24 | column_count = 5 25 | self.assertEqual(self.form.video_table.rowCount(), row_count) 26 | self.assertEqual(self.form.video_table.columnCount(), column_count) 27 | 28 | # ensure empty table upon start 29 | for row in range(row_count): 30 | for column in range(column_count): 31 | self.assertEqual(self.form.video_table.item(row, column), None) 32 | 33 | def test_user_input_defaults(self): 34 | """Test default inputs for users.""" 35 | default_download_dir = self.form._get_parent_current_dir(self.form.download_dir) 36 | self.assertEqual(self.form.download_folder_select.text(), default_download_dir) 37 | self.assertEqual(self.form.url_input.text(), "") 38 | self.assertEqual(self.form.video_info_input.text(), "") 39 | 40 | def test_button_labels(self): 41 | """Test default button labels on GUI""" 42 | self.assertEqual(self.form.cancel_button.text(), "Cancel") 43 | self.assertEqual(self.form.change_video_info_input.text(), "Replace") 44 | self.assertEqual(self.form.change_video_info_input_all.text(), "Replace all") 45 | self.assertEqual(self.form.download_button.text(), "Download") 46 | self.assertEqual(self.form.download_path.text(), "Select") 47 | self.assertEqual(self.form.itunes_annotate.text(), "Ask butler") 48 | self.assertEqual(self.form.revert_annotate.text(), "Go back") 49 | self.assertEqual(self.form.save_as_mp3_box.text(), "MP3") 50 | self.assertEqual(self.form.save_as_mp4_box.text(), "MP4") 51 | self.assertEqual(self.form.url_load_button.text(), "Load") 52 | 53 | def test_label_labels(self): 54 | """Test default labels on GUI""" 55 | self.assertEqual(self.form.download_folder_label.text(), "Download folder") 56 | self.assertEqual(self.form.download_status.text(), "") 57 | self.assertEqual(self.form.enter_playlist_url_label.text(), "Playlist or video URL") 58 | self.assertEqual(self.form.save_filetype.text(), "Save as:") 59 | self.assertEqual(self.form.title_label.text(), "YouTube to Audio") 60 | self.assertEqual(self.form.url_fetching_data_label.text(), "Loading...") 61 | self.assertEqual(self.form.url_error_label.text(), "Could not get URL. Try again.") 62 | self.assertEqual(self.form.url_poor_connection.text(), "Poor internet connection.") 63 | self.assertEqual(self.form.url_reattempt_load_label.text(), "Reattempting load...") 64 | 65 | def test_artwork_label(self): 66 | """Test default artwork label""" 67 | self.assertEqual(self.form.album_artwork.text(), "") 68 | 69 | def test_hyperlink_label(self): 70 | """Test default label on source code hyperlink""" 71 | self.assertEqual( 72 | self.form.credit_url.text(), 73 | 'source code', 74 | ) 75 | 76 | 77 | if __name__ == "__main__": 78 | unittest.main(verbosity=2) 79 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Test functions in utils/ directory""" 2 | import os 3 | import sys 4 | import unittest 5 | 6 | # get base directory and import util files 7 | sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 8 | from utils import _threading, download_youtube, query_itunes, query_youtube 9 | 10 | 11 | class testThreading(unittest.TestCase): 12 | """Test utils/_threading.py""" 13 | 14 | def setUp(self): 15 | pass 16 | 17 | def example_func_for_threading(self, value): 18 | """Example function for testing threading""" 19 | return value + 1 20 | 21 | def test_threading(self): 22 | """Test _threading.map_threads for proper threading functionality""" 23 | iterable = [i for i in range(500)] 24 | total_value_sum_one = _threading.map_threads(self.example_func_for_threading, iterable) 25 | self.assertEqual(len(list(total_value_sum_one)), 500) 26 | 27 | 28 | class testYouTubeQuery(unittest.TestCase): 29 | """Test utils/youtube_query.py""" 30 | 31 | def setUp(self): 32 | self.playlist_url = "https://www.youtube.com/playlist?list=OLAK5uy_kLvrSpKZQdl23lC5fpSnRtVGcjDLl2QbA" 33 | self.video_url = "https://www.youtube.com/watch?v=woG54UNJRrE" 34 | self.video_info_list = [ 35 | {"title": "Test1", "id": 1, "duration": 100}, 36 | {"title": "Test2", "id": 2, "duration": 200}, 37 | ] 38 | 39 | def test_get_youtube_playlist_content_false_override(self): 40 | """Test query_youtube.get_youtube_content with false error override 41 | for a playlist url""" 42 | override_error = False 43 | try: 44 | youtube_video_dict = query_youtube.get_youtube_content(self.playlist_url, override_error) 45 | except RuntimeError: # successfully threw RuntimeError 46 | youtube_video_dict = {} 47 | self.assertIsInstance(youtube_video_dict, dict) 48 | 49 | def test_get_youtube_video_content_false_override(self): 50 | """Test query_youtube.get_youtube_content with false error override 51 | for a video url""" 52 | override_error = False 53 | try: 54 | youtube_video_dict = query_youtube.get_youtube_content(self.video_url, override_error) 55 | except RuntimeError: 56 | youtube_video_dict = {} 57 | self.assertIsInstance(youtube_video_dict, dict) 58 | 59 | def test_get_youtube_playlist_content_true_override(self): 60 | """Test query_youtube.get_youtube_content with error override 61 | for a playlist url""" 62 | override_error = True 63 | youtube_video_dict = query_youtube.get_youtube_content(self.playlist_url, override_error) 64 | self.assertIsInstance(youtube_video_dict, dict) 65 | 66 | def test_get_youtube_video_content_true_override(self): 67 | """Test query_youtube.get_youtube_content with error override 68 | for a video url""" 69 | override_error = True 70 | youtube_video_dict = query_youtube.get_youtube_content(self.video_url, override_error) 71 | self.assertIsInstance(youtube_video_dict, dict) 72 | 73 | def test_get_playlist_video_info(self): 74 | """Test fetching individual urls in a playlist url""" 75 | youtube_playlist_videos_tuple = query_youtube.get_playlist_video_info(self.playlist_url) 76 | self.assertIsInstance(youtube_playlist_videos_tuple, tuple) 77 | 78 | def test_get_video_info_false_override(self): 79 | """Test getting video information with false error override""" 80 | override_error = False 81 | args = (self.video_url, override_error) 82 | try: 83 | video_info = query_youtube.get_video_info(args) 84 | except RuntimeError: 85 | video_info = {} 86 | self.assertIsInstance(video_info, dict) 87 | 88 | def test_get_video_info_true_override(self): 89 | """Test getting video information with error override""" 90 | override_error = True 91 | args = (self.video_url, override_error) 92 | video_info = query_youtube.get_video_info(args) 93 | self.assertIsInstance(video_info, dict) 94 | 95 | def test_video_content_to_dict(self): 96 | """Test a list of video info dictionaries is successfully converted 97 | to a dict type""" 98 | video_list_to_dict = query_youtube.video_content_to_dict(self.video_info_list) 99 | self.assertIsInstance(video_list_to_dict, dict) 100 | 101 | 102 | class testiTunesQuery(unittest.TestCase): 103 | """Test utils/itunes_query.py""" 104 | 105 | def setUp(self): 106 | # threading is accomplished in main.py 107 | self.youtube_video_key_value = ( 108 | "Bob Marley - Blackman Redemption", 109 | {"id": "KlmPOxwoC6Y", "duration": 212}, 110 | ) 111 | self.row_index = 0 112 | self.video_url_for_oembed = "https://www.youtube.com/watch?v=woG54UNJRrE" 113 | 114 | def test_thread_query_itunes(self): 115 | """Test accurate parsing of youtube video url to retrieve 116 | iTunes metadata.""" 117 | args = (self.row_index, self.youtube_video_key_value) 118 | itunes_return_arg = query_itunes.thread_query_itunes(args) 119 | return_row_index = itunes_return_arg[0] 120 | return_itunes_json = itunes_return_arg[1] 121 | 122 | self.assertEqual(return_row_index, 0) 123 | self.assertIsInstance(return_itunes_json, dict) 124 | 125 | def test_get_itunes_metadata(self): 126 | """Test retrieving iTunes metadata as a high level function""" 127 | itunes_meta_data = query_itunes.get_itunes_metadata(self.video_url_for_oembed) 128 | self.assertIsInstance(itunes_meta_data, dict) 129 | 130 | def test_oembed_title_non_url(self): 131 | """Test converting a non-url string to oembed. Should raise 132 | TypeError""" 133 | with self.assertRaises(TypeError): 134 | query_itunes.oembed_title("invalid_url") 135 | 136 | def test_oembed_title_url(self): 137 | """Test the conversion of a youtube video url to an oembed format 138 | for simple extraction of video information.""" 139 | video_title = query_itunes.oembed_title(self.video_url_for_oembed) 140 | self.assertIsInstance(video_title, str) 141 | 142 | def test_query_itunes(self): 143 | """Test low level function to fetch iTunes metadata based on the 144 | youtube video title.""" 145 | youtube_video_title = self.youtube_video_key_value[0] 146 | itunes_query_results = query_itunes.query_itunes(youtube_video_title) 147 | self.assertIsInstance(itunes_query_results, list) 148 | 149 | 150 | class testYouTubeDownload(unittest.TestCase): 151 | """Test utils/download_youtube.py""" 152 | 153 | def setUp(self): 154 | self.test_dirpath = os.path.dirname(os.path.abspath(__file__)) 155 | self.test_mp4_dirpath = os.path.join(self.test_dirpath, "mp4") 156 | 157 | self.mp3_args_for_thread_query_youtube = ( 158 | ("No Time This Time - The Police", {"id": "nbXACcsTn84", "duration": 198}), 159 | ( 160 | self.test_dirpath, 161 | self.test_mp4_dirpath, 162 | ), 163 | { 164 | "song": "No Time This Time", 165 | "album": "Reggatta de Blanc (Remastered)", 166 | "artist": "The Police", 167 | "genre": "Rock", 168 | "artwork": "https://is2-ssl.mzstatic.com/image/thumb/Music128/v4/21/94/c7/2194c796-c7f0-2c4b-2f94-ac247bab22a5/source/600x600bb.jpg", 169 | }, 170 | False, 171 | ) 172 | self.mp4_args_for_thread_query_youtube = ( 173 | ("No Time This Time - The Police", {"id": "nbXACcsTn84", "duration": 198}), 174 | ( 175 | self.test_dirpath, 176 | self.test_mp4_dirpath, 177 | ), 178 | { 179 | "song": "No Time This Time", 180 | "album": "Reggatta de Blanc (Remastered)", 181 | "artist": "The Police", 182 | "genre": "Rock", 183 | "artwork": "https://is2-ssl.mzstatic.com/image/thumb/Music128/v4/21/94/c7/2194c796-c7f0-2c4b-2f94-ac247bab22a5/source/600x600bb.jpg", 184 | }, 185 | True, 186 | ) 187 | self.mp3_filepath = os.path.join(self.test_dirpath, "No Time This Time.mp3") 188 | self.m4a_filepath = os.path.join(self.test_dirpath, "No Time This Time.m4a") 189 | 190 | def test_get_youtube_mp4(self): 191 | """Test download of mp4 file (m4a) using the setUp var above""" 192 | try: 193 | download_youtube.thread_query_youtube(self.mp4_args_for_thread_query_youtube) 194 | assert os.path.exists(self.m4a_filepath) 195 | finally: 196 | os.remove(self.m4a_filepath) # remove generated m4a file 197 | 198 | def test_get_youtube_mp3(self): 199 | """Test download of mp3 file (mp3) using the setUp var above""" 200 | try: 201 | download_youtube.thread_query_youtube(self.mp3_args_for_thread_query_youtube) 202 | assert os.path.exists(self.mp3_filepath) 203 | finally: 204 | os.remove(self.mp3_filepath) # remove generated mp3 file 205 | 206 | def tearDown(self): 207 | import shutil 208 | 209 | if os.path.exists(self.test_mp4_dirpath): 210 | # remove mp4 dir if it exists 211 | shutil.rmtree(self.test_mp4_dirpath) 212 | 213 | # TODO: add tests for mp3 and mp4 annotations -- above tests are for high-level functions. 214 | 215 | 216 | if __name__ == "__main__": 217 | unittest.main(verbosity=2) 218 | -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- 1 | """Allow access to yt2mp3 from ui.""" 2 | 3 | from ui.yt2mp3 import Ui_MainWindow as UiMainWindow 4 | -------------------------------------------------------------------------------- /ui/yt2mp3.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'ui/yt2mp3.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.14.2 6 | # 7 | # WARNING! All changes made in this file will be lost! 8 | 9 | 10 | from PyQt5 import QtCore, QtGui, QtWidgets 11 | 12 | 13 | class Ui_MainWindow(object): 14 | def setupUi(self, MainWindow): 15 | MainWindow.setObjectName("MainWindow") 16 | MainWindow.resize(951, 664) 17 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) 18 | sizePolicy.setHorizontalStretch(0) 19 | sizePolicy.setVerticalStretch(0) 20 | sizePolicy.setHeightForWidth(MainWindow.sizePolicy().hasHeightForWidth()) 21 | MainWindow.setSizePolicy(sizePolicy) 22 | font = QtGui.QFont() 23 | font.setFamily("Arial") 24 | font.setBold(True) 25 | font.setUnderline(False) 26 | font.setWeight(75) 27 | MainWindow.setFont(font) 28 | MainWindow.setAutoFillBackground(False) 29 | MainWindow.setStyleSheet("") 30 | self.centralwidget = QtWidgets.QWidget(MainWindow) 31 | self.centralwidget.setObjectName("centralwidget") 32 | self.title_label = QtWidgets.QLabel(self.centralwidget) 33 | self.title_label.setGeometry(QtCore.QRect(340, 10, 270, 41)) 34 | font = QtGui.QFont() 35 | font.setFamily("Copperplate") 36 | font.setPointSize(28) 37 | font.setBold(False) 38 | font.setItalic(False) 39 | font.setUnderline(False) 40 | font.setWeight(50) 41 | font.setKerning(True) 42 | self.title_label.setFont(font) 43 | self.title_label.setStyleSheet("") 44 | self.title_label.setText("YouTube to Audio") 45 | self.title_label.setObjectName("title_label") 46 | self.enter_playlist_url_label = QtWidgets.QLabel(self.centralwidget) 47 | self.enter_playlist_url_label.setGeometry(QtCore.QRect(140, 450, 171, 21)) 48 | font = QtGui.QFont() 49 | font.setFamily("Arial") 50 | font.setPointSize(12) 51 | font.setBold(False) 52 | font.setUnderline(False) 53 | font.setWeight(50) 54 | self.enter_playlist_url_label.setFont(font) 55 | self.enter_playlist_url_label.setObjectName("enter_playlist_url_label") 56 | self.url_input = QtWidgets.QLineEdit(self.centralwidget) 57 | self.url_input.setGeometry(QtCore.QRect(140, 471, 550, 21)) 58 | font = QtGui.QFont() 59 | font.setFamily("Arial") 60 | self.url_input.setFont(font) 61 | self.url_input.setStyleSheet("color: rgb(240, 240, 240)") 62 | self.url_input.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) 63 | self.url_input.setObjectName("url_input") 64 | self.url_load_button = QtWidgets.QPushButton(self.centralwidget) 65 | self.url_load_button.setGeometry(QtCore.QRect(40, 470, 90, 23)) 66 | font = QtGui.QFont() 67 | font.setFamily("Arial") 68 | self.url_load_button.setFont(font) 69 | self.url_load_button.setObjectName("url_load_button") 70 | self.remove_from_table_button = QtWidgets.QPushButton(self.centralwidget) 71 | self.remove_from_table_button.setGeometry(QtCore.QRect(760, 370, 150, 23)) 72 | font = QtGui.QFont() 73 | font.setFamily("Arial") 74 | self.remove_from_table_button.setFont(font) 75 | self.remove_from_table_button.setObjectName("remove_from_table_button") 76 | self.url_error_label = QtWidgets.QLabel(self.centralwidget) 77 | self.url_error_label.setGeometry(QtCore.QRect(525, 452, 170, 16)) 78 | font = QtGui.QFont() 79 | font.setFamily("Arial") 80 | font.setPointSize(12) 81 | font.setBold(False) 82 | font.setItalic(False) 83 | font.setUnderline(False) 84 | font.setWeight(50) 85 | self.url_error_label.setFont(font) 86 | self.url_error_label.setObjectName("url_error_label") 87 | self.url_fetching_data_label = QtWidgets.QLabel(self.centralwidget) 88 | self.url_fetching_data_label.setGeometry(QtCore.QRect(630, 450, 70, 21)) 89 | font = QtGui.QFont() 90 | font.setFamily("Arial") 91 | font.setPointSize(12) 92 | font.setBold(False) 93 | font.setItalic(False) 94 | font.setUnderline(False) 95 | font.setWeight(50) 96 | self.url_fetching_data_label.setFont(font) 97 | self.url_fetching_data_label.setObjectName("url_fetching_data_label") 98 | self.download_button = QtWidgets.QPushButton(self.centralwidget) 99 | self.download_button.setGeometry(QtCore.QRect(40, 594, 110, 23)) 100 | font = QtGui.QFont() 101 | font.setFamily("Arial") 102 | self.download_button.setFont(font) 103 | self.download_button.setObjectName("download_button") 104 | self.download_path = QtWidgets.QPushButton(self.centralwidget) 105 | self.download_path.setGeometry(QtCore.QRect(40, 524, 90, 23)) 106 | font = QtGui.QFont() 107 | font.setFamily("Arial") 108 | self.download_path.setFont(font) 109 | self.download_path.setAutoDefault(False) 110 | self.download_path.setObjectName("download_path") 111 | self.download_folder_select = QtWidgets.QLabel(self.centralwidget) 112 | self.download_folder_select.setGeometry(QtCore.QRect(140, 528, 430, 21)) 113 | font = QtGui.QFont() 114 | font.setFamily("Arial") 115 | self.download_folder_select.setFont(font) 116 | self.download_folder_select.setStyleSheet("border-color: rgb(255, 255, 255)") 117 | self.download_folder_select.setFrameShape(QtWidgets.QFrame.StyledPanel) 118 | self.download_folder_select.setFrameShadow(QtWidgets.QFrame.Plain) 119 | self.download_folder_select.setObjectName("download_folder_select") 120 | self.video_table = QtWidgets.QTableWidget(self.centralwidget) 121 | self.video_table.setGeometry(QtCore.QRect(40, 70, 871, 284)) 122 | font = QtGui.QFont() 123 | font.setFamily("Arial") 124 | font.setPointSize(13) 125 | self.video_table.setFont(font) 126 | self.video_table.setFocusPolicy(QtCore.Qt.StrongFocus) 127 | self.video_table.setStyleSheet("color: rgb(240, 240, 240)") 128 | self.video_table.setMidLineWidth(0) 129 | self.video_table.setRowCount(250) 130 | self.video_table.setColumnCount(5) 131 | self.video_table.setObjectName("video_table") 132 | item = QtWidgets.QTableWidgetItem() 133 | font = QtGui.QFont() 134 | font.setFamily("Arial") 135 | font.setPointSize(13) 136 | item.setFont(font) 137 | self.video_table.setHorizontalHeaderItem(0, item) 138 | item = QtWidgets.QTableWidgetItem() 139 | font = QtGui.QFont() 140 | font.setFamily("Arial") 141 | font.setPointSize(13) 142 | item.setFont(font) 143 | self.video_table.setHorizontalHeaderItem(1, item) 144 | item = QtWidgets.QTableWidgetItem() 145 | font = QtGui.QFont() 146 | font.setFamily("Arial") 147 | font.setPointSize(13) 148 | item.setFont(font) 149 | self.video_table.setHorizontalHeaderItem(2, item) 150 | item = QtWidgets.QTableWidgetItem() 151 | font = QtGui.QFont() 152 | font.setFamily("Arial") 153 | font.setPointSize(13) 154 | item.setFont(font) 155 | self.video_table.setHorizontalHeaderItem(3, item) 156 | item = QtWidgets.QTableWidgetItem() 157 | font = QtGui.QFont() 158 | font.setFamily("Arial") 159 | font.setPointSize(13) 160 | item.setFont(font) 161 | self.video_table.setHorizontalHeaderItem(4, item) 162 | self.video_table.horizontalHeader().setVisible(True) 163 | self.video_table.horizontalHeader().setCascadingSectionResizes(False) 164 | self.video_table.horizontalHeader().setDefaultSectionSize(164) 165 | self.video_table.horizontalHeader().setMinimumSectionSize(19) 166 | self.video_table.verticalHeader().setDefaultSectionSize(34) 167 | self.cancel_button = QtWidgets.QPushButton(self.centralwidget) 168 | self.cancel_button.setGeometry(QtCore.QRect(160, 594, 110, 23)) 169 | font = QtGui.QFont() 170 | font.setFamily("Arial") 171 | self.cancel_button.setFont(font) 172 | self.cancel_button.setObjectName("cancel_button") 173 | self.download_folder_label = QtWidgets.QLabel(self.centralwidget) 174 | self.download_folder_label.setGeometry(QtCore.QRect(140, 507, 151, 21)) 175 | font = QtGui.QFont() 176 | font.setFamily("Arial") 177 | font.setPointSize(12) 178 | font.setBold(False) 179 | font.setUnderline(False) 180 | font.setWeight(50) 181 | self.download_folder_label.setFont(font) 182 | self.download_folder_label.setObjectName("download_folder_label") 183 | self.video_info_input = QtWidgets.QLineEdit(self.centralwidget) 184 | self.video_info_input.setGeometry(QtCore.QRect(180, 370, 510, 21)) 185 | font = QtGui.QFont() 186 | font.setFamily("Arial") 187 | self.video_info_input.setFont(font) 188 | self.video_info_input.setFocusPolicy(QtCore.Qt.StrongFocus) 189 | self.video_info_input.setStyleSheet("color: rgb(240, 240, 240)") 190 | self.video_info_input.setCursorMoveStyle(QtCore.Qt.LogicalMoveStyle) 191 | self.video_info_input.setObjectName("video_info_input") 192 | self.itunes_annotate = QtWidgets.QPushButton(self.centralwidget) 193 | self.itunes_annotate.setGeometry(QtCore.QRect(40, 370, 130, 21)) 194 | font = QtGui.QFont() 195 | font.setFamily("Arial") 196 | self.itunes_annotate.setFont(font) 197 | self.itunes_annotate.setObjectName("itunes_annotate") 198 | self.revert_annotate = QtWidgets.QPushButton(self.centralwidget) 199 | self.revert_annotate.setGeometry(QtCore.QRect(40, 370, 130, 21)) 200 | font = QtGui.QFont() 201 | font.setFamily("Arial") 202 | self.revert_annotate.setFont(font) 203 | self.revert_annotate.setObjectName("revert_annotate") 204 | self.album_artwork = QtWidgets.QLabel(self.centralwidget) 205 | self.album_artwork.setGeometry(QtCore.QRect(710, 416, 201, 201)) 206 | self.album_artwork.setStyleSheet("background-color: rgb(82, 95, 106)") 207 | self.album_artwork.setFrameShape(QtWidgets.QFrame.Panel) 208 | self.album_artwork.setFrameShadow(QtWidgets.QFrame.Plain) 209 | self.album_artwork.setText("") 210 | self.album_artwork.setPixmap(QtGui.QPixmap("ui/../img/default_artwork.png")) 211 | self.album_artwork.setScaledContents(True) 212 | self.album_artwork.setAlignment(QtCore.Qt.AlignCenter) 213 | self.album_artwork.setObjectName("album_artwork") 214 | self.change_video_info_input = QtWidgets.QPushButton(self.centralwidget) 215 | self.change_video_info_input.setGeometry(QtCore.QRect(490, 400, 70, 21)) 216 | font = QtGui.QFont() 217 | font.setFamily("Arial") 218 | self.change_video_info_input.setFont(font) 219 | self.change_video_info_input.setFocusPolicy(QtCore.Qt.StrongFocus) 220 | self.change_video_info_input.setObjectName("change_video_info_input") 221 | self.download_status = QtWidgets.QLabel(self.centralwidget) 222 | self.download_status.setGeometry(QtCore.QRect(290, 594, 361, 21)) 223 | font = QtGui.QFont() 224 | font.setFamily("Arial") 225 | font.setPointSize(12) 226 | font.setBold(False) 227 | font.setUnderline(False) 228 | font.setWeight(50) 229 | self.download_status.setFont(font) 230 | self.download_status.setText("") 231 | self.download_status.setObjectName("download_status") 232 | self.change_video_info_input_all = QtWidgets.QPushButton(self.centralwidget) 233 | self.change_video_info_input_all.setGeometry(QtCore.QRect(580, 400, 110, 21)) 234 | font = QtGui.QFont() 235 | font.setFamily("Arial") 236 | self.change_video_info_input_all.setFont(font) 237 | self.change_video_info_input_all.setFocusPolicy(QtCore.Qt.StrongFocus) 238 | self.change_video_info_input_all.setObjectName("change_video_info_input_all") 239 | self.credit_url = QtWidgets.QLabel(self.centralwidget) 240 | self.credit_url.setGeometry(QtCore.QRect(836, 48, 80, 21)) 241 | font = QtGui.QFont() 242 | font.setFamily("Arial") 243 | font.setPointSize(12) 244 | font.setBold(False) 245 | font.setUnderline(False) 246 | font.setWeight(50) 247 | self.credit_url.setFont(font) 248 | self.credit_url.setObjectName("credit_url") 249 | self.save_as_mp4_box = QtWidgets.QCheckBox(self.centralwidget) 250 | self.save_as_mp4_box.setGeometry(QtCore.QRect(638, 528, 60, 22)) 251 | font = QtGui.QFont() 252 | font.setFamily("Arial") 253 | font.setPointSize(13) 254 | self.save_as_mp4_box.setFont(font) 255 | self.save_as_mp4_box.setObjectName("save_as_mp4_box") 256 | self.save_as_mp3_box = QtWidgets.QCheckBox(self.centralwidget) 257 | self.save_as_mp3_box.setGeometry(QtCore.QRect(582, 528, 54, 22)) 258 | font = QtGui.QFont() 259 | font.setFamily("Arial") 260 | font.setPointSize(13) 261 | self.save_as_mp3_box.setFont(font) 262 | self.save_as_mp3_box.setObjectName("save_as_mp3_box") 263 | self.save_filetype = QtWidgets.QLabel(self.centralwidget) 264 | self.save_filetype.setGeometry(QtCore.QRect(582, 507, 70, 21)) 265 | font = QtGui.QFont() 266 | font.setFamily("Arial") 267 | font.setPointSize(12) 268 | font.setBold(False) 269 | font.setUnderline(False) 270 | font.setWeight(50) 271 | self.save_filetype.setFont(font) 272 | self.save_filetype.setObjectName("save_filetype") 273 | self.url_reattempt_load_label = QtWidgets.QLabel(self.centralwidget) 274 | self.url_reattempt_load_label.setGeometry(QtCore.QRect(576, 450, 120, 21)) 275 | font = QtGui.QFont() 276 | font.setFamily("Arial") 277 | font.setPointSize(12) 278 | font.setBold(False) 279 | font.setItalic(False) 280 | font.setUnderline(False) 281 | font.setWeight(50) 282 | self.url_reattempt_load_label.setFont(font) 283 | self.url_reattempt_load_label.setObjectName("url_reattempt_load_label") 284 | self.url_poor_connection = QtWidgets.QLabel(self.centralwidget) 285 | self.url_poor_connection.setGeometry(QtCore.QRect(550, 450, 140, 21)) 286 | font = QtGui.QFont() 287 | font.setFamily("Arial") 288 | font.setPointSize(12) 289 | font.setBold(False) 290 | font.setItalic(False) 291 | font.setUnderline(False) 292 | font.setWeight(50) 293 | self.url_poor_connection.setFont(font) 294 | self.url_poor_connection.setObjectName("url_poor_connection") 295 | MainWindow.setCentralWidget(self.centralwidget) 296 | self.menubar = QtWidgets.QMenuBar(MainWindow) 297 | self.menubar.setGeometry(QtCore.QRect(0, 0, 951, 22)) 298 | self.menubar.setObjectName("menubar") 299 | MainWindow.setMenuBar(self.menubar) 300 | self.statusbar = QtWidgets.QStatusBar(MainWindow) 301 | self.statusbar.setObjectName("statusbar") 302 | MainWindow.setStatusBar(self.statusbar) 303 | 304 | self.retranslateUi(MainWindow) 305 | QtCore.QMetaObject.connectSlotsByName(MainWindow) 306 | 307 | def retranslateUi(self, MainWindow): 308 | _translate = QtCore.QCoreApplication.translate 309 | MainWindow.setWindowTitle(_translate("MainWindow", "YouTube to Audio")) 310 | self.enter_playlist_url_label.setText(_translate("MainWindow", "Playlist or video URL")) 311 | self.url_load_button.setText(_translate("MainWindow", "Load")) 312 | self.remove_from_table_button.setText(_translate("MainWindow", "Remove video")) 313 | self.url_error_label.setText(_translate("MainWindow", "Could not get URL. Try again.")) 314 | self.url_fetching_data_label.setText(_translate("MainWindow", "Loading...")) 315 | self.download_button.setText(_translate("MainWindow", "Download")) 316 | self.download_path.setText(_translate("MainWindow", "Select")) 317 | self.download_folder_select.setText(_translate("MainWindow", "Folder: ")) 318 | self.video_table.setSortingEnabled(False) 319 | item = self.video_table.horizontalHeaderItem(0) 320 | item.setText(_translate("MainWindow", "song")) 321 | item = self.video_table.horizontalHeaderItem(1) 322 | item.setText(_translate("MainWindow", "album")) 323 | item = self.video_table.horizontalHeaderItem(2) 324 | item.setText(_translate("MainWindow", "artist")) 325 | item = self.video_table.horizontalHeaderItem(3) 326 | item.setText(_translate("MainWindow", "genre")) 327 | item = self.video_table.horizontalHeaderItem(4) 328 | item.setText(_translate("MainWindow", "artwork")) 329 | self.cancel_button.setText(_translate("MainWindow", "Cancel")) 330 | self.download_folder_label.setText(_translate("MainWindow", "Download folder")) 331 | self.itunes_annotate.setText(_translate("MainWindow", "Ask butler")) 332 | self.revert_annotate.setText(_translate("MainWindow", "Go back")) 333 | self.change_video_info_input.setText(_translate("MainWindow", "Replace")) 334 | self.change_video_info_input_all.setText(_translate("MainWindow", "Replace all")) 335 | self.credit_url.setText(_translate("MainWindow", "source code")) 336 | self.save_as_mp4_box.setText(_translate("MainWindow", "MP4")) 337 | self.save_as_mp3_box.setText(_translate("MainWindow", "MP3")) 338 | self.save_filetype.setText(_translate("MainWindow", "Save as:")) 339 | self.url_reattempt_load_label.setText(_translate("MainWindow", "Reattempting load...")) 340 | self.url_poor_connection.setText(_translate("MainWindow", "Poor internet connection.")) 341 | -------------------------------------------------------------------------------- /ui/yt2mp3.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 951 10 | 664 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | 21 | Arial 22 | 75 23 | true 24 | false 25 | 26 | 27 | 28 | YouTube to Audio 29 | 30 | 31 | false 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 340 41 | 10 42 | 270 43 | 41 44 | 45 | 46 | 47 | 48 | Copperplate 49 | 28 50 | 50 51 | false 52 | false 53 | false 54 | true 55 | 56 | 57 | 58 | 59 | 60 | 61 | YouTube to Audio 62 | 63 | 64 | 65 | 66 | 67 | 140 68 | 450 69 | 171 70 | 21 71 | 72 | 73 | 74 | 75 | Arial 76 | 12 77 | 50 78 | false 79 | false 80 | 81 | 82 | 83 | Playlist or video URL 84 | 85 | 86 | 87 | 88 | 89 | 140 90 | 471 91 | 550 92 | 21 93 | 94 | 95 | 96 | 97 | Arial 98 | 99 | 100 | 101 | color: rgb(240, 240, 240) 102 | 103 | 104 | Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter 105 | 106 | 107 | 108 | 109 | 110 | 40 111 | 470 112 | 90 113 | 23 114 | 115 | 116 | 117 | 118 | Arial 119 | 120 | 121 | 122 | Load 123 | 124 | 125 | 126 | 127 | 128 | 760 129 | 370 130 | 150 131 | 23 132 | 133 | 134 | 135 | 136 | Arial 137 | 138 | 139 | 140 | Remove video 141 | 142 | 143 | 144 | 145 | 146 | 525 147 | 452 148 | 170 149 | 16 150 | 151 | 152 | 153 | 154 | Arial 155 | 12 156 | 50 157 | false 158 | false 159 | false 160 | 161 | 162 | 163 | Could not get URL. Try again. 164 | 165 | 166 | 167 | 168 | 169 | 630 170 | 450 171 | 70 172 | 21 173 | 174 | 175 | 176 | 177 | Arial 178 | 12 179 | 50 180 | false 181 | false 182 | false 183 | 184 | 185 | 186 | Loading... 187 | 188 | 189 | 190 | 191 | 192 | 40 193 | 594 194 | 110 195 | 23 196 | 197 | 198 | 199 | 200 | Arial 201 | 202 | 203 | 204 | Download 205 | 206 | 207 | 208 | 209 | 210 | 40 211 | 524 212 | 90 213 | 23 214 | 215 | 216 | 217 | 218 | Arial 219 | 220 | 221 | 222 | Select 223 | 224 | 225 | false 226 | 227 | 228 | 229 | 230 | 231 | 140 232 | 528 233 | 430 234 | 21 235 | 236 | 237 | 238 | 239 | Arial 240 | 241 | 242 | 243 | border-color: rgb(255, 255, 255) 244 | 245 | 246 | QFrame::StyledPanel 247 | 248 | 249 | QFrame::Plain 250 | 251 | 252 | Folder: 253 | 254 | 255 | 256 | 257 | 258 | 40 259 | 70 260 | 871 261 | 284 262 | 263 | 264 | 265 | 266 | Arial 267 | 13 268 | 269 | 270 | 271 | Qt::StrongFocus 272 | 273 | 274 | color: rgb(240, 240, 240) 275 | 276 | 277 | 0 278 | 279 | 280 | false 281 | 282 | 283 | 250 284 | 285 | 286 | 5 287 | 288 | 289 | true 290 | 291 | 292 | false 293 | 294 | 295 | 164 296 | 297 | 298 | 19 299 | 300 | 301 | 34 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | song 556 | 557 | 558 | 559 | Arial 560 | 13 561 | 562 | 563 | 564 | 565 | 566 | album 567 | 568 | 569 | 570 | Arial 571 | 13 572 | 573 | 574 | 575 | 576 | 577 | artist 578 | 579 | 580 | 581 | Arial 582 | 13 583 | 584 | 585 | 586 | 587 | 588 | genre 589 | 590 | 591 | 592 | Arial 593 | 13 594 | 595 | 596 | 597 | 598 | 599 | artwork 600 | 601 | 602 | 603 | Arial 604 | 13 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 160 613 | 594 614 | 110 615 | 23 616 | 617 | 618 | 619 | 620 | Arial 621 | 622 | 623 | 624 | Cancel 625 | 626 | 627 | 628 | 629 | 630 | 140 631 | 507 632 | 151 633 | 21 634 | 635 | 636 | 637 | 638 | Arial 639 | 12 640 | 50 641 | false 642 | false 643 | 644 | 645 | 646 | Download folder 647 | 648 | 649 | 650 | 651 | 652 | 180 653 | 370 654 | 510 655 | 21 656 | 657 | 658 | 659 | 660 | Arial 661 | 662 | 663 | 664 | Qt::StrongFocus 665 | 666 | 667 | color: rgb(240, 240, 240) 668 | 669 | 670 | Qt::LogicalMoveStyle 671 | 672 | 673 | 674 | 675 | 676 | 40 677 | 370 678 | 130 679 | 21 680 | 681 | 682 | 683 | 684 | Arial 685 | 686 | 687 | 688 | Ask butler 689 | 690 | 691 | 692 | 693 | 694 | 40 695 | 370 696 | 130 697 | 21 698 | 699 | 700 | 701 | 702 | Arial 703 | 704 | 705 | 706 | Go back 707 | 708 | 709 | 710 | 711 | 712 | 710 713 | 416 714 | 201 715 | 201 716 | 717 | 718 | 719 | background-color: rgb(82, 95, 106) 720 | 721 | 722 | QFrame::Panel 723 | 724 | 725 | QFrame::Plain 726 | 727 | 728 | 729 | 730 | 731 | ../img/default_artwork.png 732 | 733 | 734 | true 735 | 736 | 737 | Qt::AlignCenter 738 | 739 | 740 | 741 | 742 | 743 | 490 744 | 400 745 | 70 746 | 21 747 | 748 | 749 | 750 | 751 | Arial 752 | 753 | 754 | 755 | Qt::StrongFocus 756 | 757 | 758 | Replace 759 | 760 | 761 | 762 | 763 | 764 | 290 765 | 594 766 | 361 767 | 21 768 | 769 | 770 | 771 | 772 | Arial 773 | 12 774 | 50 775 | false 776 | false 777 | 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 580 787 | 400 788 | 110 789 | 21 790 | 791 | 792 | 793 | 794 | Arial 795 | 796 | 797 | 798 | Qt::StrongFocus 799 | 800 | 801 | Replace all 802 | 803 | 804 | 805 | 806 | 807 | 836 808 | 48 809 | 80 810 | 21 811 | 812 | 813 | 814 | 815 | Arial 816 | 12 817 | 50 818 | false 819 | false 820 | 821 | 822 | 823 | source code 824 | 825 | 826 | 827 | 828 | 829 | 638 830 | 528 831 | 60 832 | 22 833 | 834 | 835 | 836 | 837 | Arial 838 | 13 839 | 840 | 841 | 842 | MP4 843 | 844 | 845 | 846 | 847 | 848 | 582 849 | 528 850 | 54 851 | 22 852 | 853 | 854 | 855 | 856 | Arial 857 | 13 858 | 859 | 860 | 861 | MP3 862 | 863 | 864 | 865 | 866 | 867 | 582 868 | 507 869 | 70 870 | 21 871 | 872 | 873 | 874 | 875 | Arial 876 | 12 877 | 50 878 | false 879 | false 880 | 881 | 882 | 883 | Save as: 884 | 885 | 886 | 887 | 888 | 889 | 576 890 | 450 891 | 120 892 | 21 893 | 894 | 895 | 896 | 897 | Arial 898 | 12 899 | 50 900 | false 901 | false 902 | false 903 | 904 | 905 | 906 | Reattempting load... 907 | 908 | 909 | 910 | 911 | 912 | 550 913 | 450 914 | 140 915 | 21 916 | 917 | 918 | 919 | 920 | Arial 921 | 12 922 | 50 923 | false 924 | false 925 | false 926 | 927 | 928 | 929 | Poor internet connection. 930 | 931 | 932 | 933 | 934 | 935 | 936 | 0 937 | 0 938 | 951 939 | 22 940 | 941 | 942 | 943 | 944 | 945 | 946 | 947 | 948 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Allow access to four methods from utils""" 2 | 3 | from utils._threading import map_threads 4 | from utils.query_itunes import thread_query_itunes 5 | from utils.query_youtube import get_youtube_content 6 | from utils.download_youtube import thread_query_youtube 7 | -------------------------------------------------------------------------------- /utils/_threading.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | 3 | 4 | def map_threads(func, _iterable): 5 | """Map function with iterable object in using thread pools.""" 6 | with concurrent.futures.ThreadPoolExecutor() as executor: 7 | result = executor.map(func, _iterable) 8 | return result 9 | 10 | 11 | def map_processes(func, _iterable): 12 | """Map function with iterable object in using process pools.""" 13 | with concurrent.futures.ProcessPoolExecutor() as executor: 14 | result = executor.map(func, _iterable) 15 | return result 16 | -------------------------------------------------------------------------------- /utils/download_youtube.py: -------------------------------------------------------------------------------- 1 | import os 2 | from shutil import copy2 3 | 4 | from pytubefix import YouTube 5 | import requests 6 | from mutagen.mp3 import MP3 7 | from mutagen.mp4 import MP4, MP4Cover 8 | from mutagen.id3 import ID3, APIC, TALB, TPE1, TIT2, TCON 9 | from moviepy.editor import VideoFileClip 10 | 11 | 12 | def thread_query_youtube(args): 13 | """Download video to mp4 then mp3 -- triggered 14 | by map_threads""" 15 | 16 | yt_link_starter = "https://www.youtube.com/watch?v=" 17 | _, videos_dict = args[0] 18 | download_path, mp4_path = args[1] 19 | song_properties = args[2] 20 | save_as_mp4 = args[3] 21 | full_link = yt_link_starter + videos_dict["id"] 22 | 23 | def new_get_youtube_mp3(): 24 | try: 25 | yt = YouTube(full_link) 26 | print(yt.title) 27 | 28 | ys = yt.streams.get_audio_only() 29 | ys.download(mp3=True) 30 | except Exception as error: # not a good Exceptions catch... 31 | print(f"Error: {str(error)}") # poor man's logging 32 | raise RuntimeError from error 33 | 34 | def get_youtube_mp4(): 35 | """Write MP4 audio file from YouTube video.""" 36 | try: 37 | video = YouTube(full_link) 38 | stream = video.streams.get_highest_resolution() 39 | mp4_filename = f'{song_properties.get("song")}' 40 | illegal_char = ( 41 | "?", 42 | "'", 43 | '"', 44 | ".", 45 | "/", 46 | "\\", 47 | "*", 48 | "^", 49 | "%", 50 | "$", 51 | "#", 52 | "~", 53 | "<", 54 | ">", 55 | ",", 56 | ";", 57 | ":", 58 | "|", 59 | ) 60 | # remove illegal characters from song title - otherwise clipped by pytube 61 | for char in illegal_char: 62 | mp4_filename = mp4_filename.replace(char, "") 63 | 64 | mp4_filename += ".mp4" # add extension for downstream file recognition 65 | stream.download(mp4_path, filename=f"{mp4_filename}") 66 | if save_as_mp4: 67 | m4a_filename = f'{song_properties.get("song")}.m4a' 68 | # Copy song from temporary folder to destination 69 | copy2( 70 | os.path.join(mp4_path, mp4_filename), 71 | os.path.join(download_path, m4a_filename), 72 | ) 73 | return set_song_metadata(download_path, song_properties, m4a_filename, True) 74 | 75 | return get_youtube_mp3(mp4_filename) 76 | except Exception as error: # not a good Exceptions catch... 77 | print(f"Error: {str(error)}") # poor man's logging 78 | raise RuntimeError from error 79 | 80 | def get_youtube_mp3(mp4_filename): 81 | """Write MP3 audio file from MP4.""" 82 | mp3_filename = f'{song_properties.get("song")}.mp3' 83 | try: 84 | video = VideoFileClip(os.path.join(mp4_path, mp4_filename)) 85 | video.audio.write_audiofile(os.path.join(download_path, mp3_filename)) 86 | set_song_metadata(download_path, song_properties, mp3_filename, False) 87 | except Exception as e: 88 | print(e) 89 | finally: 90 | video.close() 91 | 92 | return get_youtube_mp4() 93 | 94 | 95 | def set_song_metadata(directory, song_properties, song_filename, save_as_mp4): 96 | """Set song metadata.""" 97 | 98 | def write_to_mp4(): 99 | """Add metadata to MP4 file.""" 100 | # NOTE Metadata for MP4 will fail to write if any error (esp. with artwork) occurs 101 | audio = MP4(os.path.join(directory, song_filename)) 102 | audio.tags["\xa9alb"] = song_properties["album"] 103 | audio.tags["\xa9ART"] = song_properties["artist"] 104 | audio.tags["\xa9nam"] = song_properties["song"] 105 | audio.tags["\xa9gen"] = song_properties["genre"] 106 | # Only add a cover if the response was ok and the header of the 107 | # response content is that of a JPEG image. Source: 108 | # https://www.file-recovery.com/jpg-signature-format.htm. 109 | if valid_artwork(): 110 | audio.tags["covr"] = [MP4Cover(response.content, imageformat=MP4Cover.FORMAT_JPEG)] 111 | 112 | audio.save() 113 | 114 | def write_to_mp3(): 115 | """Add metadata to MP3 file.""" 116 | audio = MP3(os.path.join(directory, song_filename), ID3=ID3) 117 | audio["TALB"] = TALB(encoding=3, text=song_properties["album"]) 118 | audio["TPE1"] = TPE1(encoding=3, text=song_properties["artist"]) 119 | audio["TIT2"] = TIT2(encoding=3, text=song_properties["song"]) 120 | audio["TCON"] = TCON(encoding=3, text=song_properties["genre"]) 121 | if valid_artwork(): 122 | audio.tags.add( 123 | APIC( 124 | encoding=3, # 3 is for utf-8 125 | mime="image/jpeg", # image/jpeg or image/png 126 | type=3, # 3 is for the cover image 127 | desc="Cover", 128 | data=response.content, 129 | ) 130 | ) 131 | 132 | audio.save() 133 | 134 | def valid_artwork(): 135 | """Validate artwork requests response.""" 136 | return response is not None and response.status_code == 200 and response.content[:3] == b"\xff\xd8\xff" 137 | 138 | # TODO Cache the image until program finishes 139 | try: 140 | # Get byte data for album artwork url. The first number in the timeout 141 | # tuple is for the initial connection to the server. The second number 142 | # is for the subsequent response from the server. 143 | response = requests.get(song_properties["artwork"], timeout=(1, 5)) 144 | except requests.exceptions.MissingSchema: 145 | response = None 146 | 147 | if save_as_mp4: 148 | write_to_mp4() 149 | else: 150 | write_to_mp3() 151 | -------------------------------------------------------------------------------- /utils/query_itunes.py: -------------------------------------------------------------------------------- 1 | from json.decoder import JSONDecodeError 2 | 3 | import itunespy 4 | import requests 5 | 6 | 7 | def thread_query_itunes(args): 8 | row_index = args[0] 9 | key_value = args[1] 10 | url_id = key_value[1]["id"] 11 | vid_url = f"https://www.youtube.com/watch?v={url_id}" 12 | ITUNES_META_JSON = get_itunes_metadata(vid_url) 13 | 14 | return (row_index, ITUNES_META_JSON) 15 | 16 | 17 | def get_itunes_metadata(vid_url): 18 | """Get iTunes metadata to add to MP3/MP4 file.""" 19 | vid_title = oembed_title(vid_url) 20 | try: 21 | ITUNES_META = query_itunes(vid_title)[0] 22 | except TypeError: # i.e. no information fetched from query_itunes 23 | return None 24 | 25 | ITUNES_META_JSON = { 26 | "track_name": ITUNES_META.track_name, 27 | "album_name": ITUNES_META.collection_name, 28 | "artist_name": ITUNES_META.artist_name, 29 | "primary_genre_name": ITUNES_META.primary_genre_name, 30 | "artwork_url_fullres": ITUNES_META.artwork_url_60.replace( 31 | "60", "600" 32 | ), # manually replace album artwork to 600x600 33 | } 34 | 35 | # get artwork content from iTunes artwork url 36 | response = requests.get(ITUNES_META_JSON["artwork_url_fullres"]) 37 | album_img = response.content 38 | ITUNES_META_JSON["artwork_bytes_fullres"] = album_img 39 | 40 | return ITUNES_META_JSON 41 | 42 | 43 | def oembed_title(vid_url): 44 | """Get YouTube video title information if input str 45 | is a url - else return input descriptor str from 46 | get_itunes_metadata.""" 47 | if vid_url.startswith("https://"): 48 | # oEmbed is a format for allowing an embedded representation 49 | # of a URL on third party sites. 50 | oembed_url = f"https://www.youtube.com/oembed?url={vid_url}&format=json" 51 | try: 52 | vid_content = requests.get(oembed_url) 53 | vid_json = vid_content.json() 54 | except (requests.exceptions.ConnectionError, JSONDecodeError): 55 | return None 56 | 57 | return vid_json["title"] 58 | 59 | raise TypeError("vid_url must be a URL string.") 60 | 61 | 62 | def query_itunes(song_properties): 63 | """Download video metadata using itunespy.""" 64 | try: 65 | song_itunes = itunespy.search_track(song_properties) 66 | # Before returning convert all the track_time values to minutes. 67 | for song in song_itunes: 68 | song.track_time = round(song.track_time / 60000, 2) 69 | return song_itunes 70 | except Exception: 71 | return None 72 | -------------------------------------------------------------------------------- /utils/query_youtube.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib 3 | 4 | import youtube_dl 5 | from pytube import Playlist 6 | from utils._threading import map_threads 7 | 8 | 9 | def get_youtube_content(youtube_url, override_error): 10 | """Str parse YouTube url and call appropriate functions 11 | to execute url content.""" 12 | if ".com/playlist" in youtube_url: 13 | url_tuple = get_playlist_video_info(youtube_url) 14 | # concatenate override_error argument to url_tuple 15 | url_tuple = tuple((url, override_error) for url in url_tuple) 16 | video_genr = map_threads(get_video_info, url_tuple) 17 | video_info = list(video_genr) 18 | else: 19 | adj_youtube_url = youtube_url.split("&")[0] # trim ascii encoding "&" 20 | # set get_video_info parameter as tuple to comply with multithreading parameter (tuple) 21 | url_tuple = (adj_youtube_url, override_error) 22 | video_json = get_video_info(url_tuple) 23 | video_info = [video_json] 24 | 25 | return video_content_to_dict(video_info) 26 | 27 | 28 | def get_playlist_video_info(playlist_url): 29 | """Get url of videos in a YouTube playlist.""" 30 | try: 31 | playlist = Playlist(playlist_url) 32 | playlist._video_regex = re.compile( 33 | r"\"url\":\"(/watch\?v=[\w-]*)" 34 | ) # important bug fix with recent YouTube update. See https://github.com/get-pytube/pytube3/pull/90 35 | # thrown if poor internet connection or bad playlist url 36 | except (urllib.error.URLError, KeyError) as error: 37 | raise RuntimeError(error) 38 | 39 | try: 40 | video_urls = tuple(playlist.video_urls) 41 | except AttributeError as error: 42 | # if videos were queried unsuccessfully in playlist 43 | raise RuntimeError(error) 44 | 45 | return video_urls 46 | 47 | 48 | def get_video_info(args): 49 | """Get YouTube video metadata.""" 50 | video_url = args[0] 51 | override_error = args[1] 52 | 53 | ydl_opts = {"ignoreerrors": False, "quiet": True} 54 | if override_error: 55 | # silence youtube_dl exceptions by ignoring errors 56 | ydl_opts = {"ignoreerrors": True, "quiet": True} 57 | 58 | try: 59 | with youtube_dl.YoutubeDL(ydl_opts) as ydl: 60 | video_info = ydl.extract_info(video_url, download=False) 61 | return video_info 62 | # video unavailable or bad url format 63 | except (youtube_dl.utils.DownloadError, UnicodeError) as error: 64 | # catch exception here and process error: 65 | # either load content again or post error label. 66 | raise RuntimeError(error) 67 | 68 | 69 | def video_content_to_dict(vid_info_list): 70 | """Convert YouTube metadata list to dictionary.""" 71 | return {video["title"]: {"id": video["id"], "duration": video["duration"]} for video in vid_info_list if video} 72 | -------------------------------------------------------------------------------- /utils/timeout.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import os 3 | import signal 4 | from functools import wraps 5 | 6 | 7 | def timeout(seconds=10, error_message=os.strerror(errno.ETIME)): 8 | """A wrapper for request methods. Default time before 9 | timeout is 10 seconds - change as necessary. This wrapper 10 | will only work in the main thread.""" 11 | 12 | def decorator(func): 13 | def _handle_timeout(signum, frame): 14 | raise TimeoutError(error_message) 15 | 16 | def wrapper(*args, **kwargs): 17 | signal.signal(signal.SIGALRM, _handle_timeout) 18 | signal.alarm(seconds) 19 | try: 20 | result = func(*args, **kwargs) 21 | finally: 22 | signal.alarm(0) 23 | return result 24 | 25 | return wraps(func)(wrapper) 26 | 27 | return decorator 28 | --------------------------------------------------------------------------------