├── .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 | 
4 | [](https://www.python.org/downloads/)
5 | [](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 | 
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 |
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 |
--------------------------------------------------------------------------------