├── LICENSE ├── README.md ├── build.py ├── icon.ico ├── requirements.txt └── youtility ├── consts.py ├── cut.py ├── downloader.py ├── get_captions.py ├── main.py ├── playlist.py ├── resources ├── dark │ └── demo.qss ├── icons │ ├── README.md │ ├── captions.svg │ ├── icon.ico │ └── icon.png ├── light │ └── demo.qss └── misc │ └── config.json └── settings.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rohan Kishore 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![Group 3 (1)](https://github.com/rohankishore/Youtility/assets/109947257/8ec73f10-0bed-4dbc-8201-269db2adaddc) 4 | 5 | YouTube video and playlist downloader made with PyQt6 and PyTube. [Download Now](https://github.com/rohankishore/Youtility/releases) 6 | 7 |
8 | 9 |
10 | 11 | Buy Me a Coffee at ko-fi.com 12 | [![Github all releases](https://img.shields.io/github/downloads/rohankishore/Youtility/total.svg)](https://GitHub.com/rohankishore/Youtility/releases/) 13 |
14 | 15 | ## 📺 What is Youtility? 16 | > "Hey, are you lonely?" 17 | 18 | 19 | > "Hot Milfs 5kms near you" 20 | 21 | > "Kylie (22F) wants to meet you" 22 | 23 |
24 | 25 | If you've tried downloading YouTube videos online, you're familiar with the above messages. Well, your saviour(me lol) is here. Meet Youtility, a YouTube downloader with NO ads or bloats, and open source :). With Youtility, say goodbye to enduring those pesky, dodgy ads while downloading YouTube videos. It's your ticket to hassle-free downloads without the BS of intrusive advertisements. 26 | 27 | **You can download** 28 | - Single videos with captions file 29 | - Playlists (also as audio-only files) 30 | - Cut and Download specific parts of a video 31 | - Video to Mp3 / FLAC 32 | - Individual caption files 33 | 34 | 35 | ## 🧩 How does it look? 36 | 37 | ![image](https://github.com/rohankishore/Youtility/assets/109947257/fe950733-4229-4073-99fa-651381528032) 38 | 39 | ![image](https://github.com/rohankishore/Youtility/assets/109947257/ff56eb68-1a03-4b82-b022-04afcf4caa8d) 40 | 41 | ![image](https://github.com/rohankishore/Youtility/assets/109947257/714e0b83-8064-49f2-92b7-728dd65623b2) 42 | 43 | ![image](https://github.com/rohankishore/Youtility/assets/109947257/87fb1b0d-f99d-4925-914a-9777a0bd04de) 44 | 45 | ![image](https://github.com/rohankishore/Youtility/assets/109947257/46fcabfd-03c5-4f88-8c81-63bb9f112890) 46 | 47 | 48 |
49 | 50 | ## 👒 Getting Started 51 | 52 | ### Download EXE 53 | - Head over to [Releases](https://github.com/rohankishore/Youtility/releases) 54 | - Download `Youtility_vX.zip` file (where X is the current latest version) 55 | - Unzip the file 56 | - Run `main.exe` 57 | 58 | ### Run Manually via Python 59 | 60 | ```bash 61 | pip install -r requirements.txt 62 | ``` 63 | 64 | - Run `main.py` 65 | 66 |
67 | 68 | 69 | ## 🤝 Show Support 70 | 71 | Hey! First of all, thank you for considering supporting me. You can support me by Buying me a Coffee via Ko-Fi. You can either [Click Here](https://ko-fi.com/rohankishore) or click the button "Buy Me A Coffee" on top of this page or via the GitHub Sponsor button. Thanks a lot :) 72 | 73 |
74 | 75 | ## 💖 Credits 76 | 77 | This project was made possible just because of [PyTube](https://github.com/pytube/pytube) and [zhiyiYo](https://github.com/zhiyiYo)'s [PyQt-Fluent-Widgets](https://github.com/zhiyiYo/PyQt-Fluent-Widgets). 78 | 79 |
80 | 81 | ## ⚠️ Disclaimer 82 | 83 | This application is intended for personal use only. The user assumes full responsibility for ensuring that the downloaded content is used in compliance with the copyright laws and regulations applicable in their jurisdiction. 84 | 85 | This application is designed to facilitate downloading videos from YouTube for offline viewing or personal use. It is not intended to be used for any commercial purposes, distribution, or sharing of copyrighted material without proper authorization from the content owners or in violation of YouTube's terms of service. 86 | 87 | The developers of this application do not endorse or promote the unauthorized downloading or distribution of copyrighted content. Users are advised to respect the intellectual property rights of content creators and to obtain proper permissions before downloading or using any content for purposes other than personal viewing. 88 | 89 | By using this application, you agree to use the downloaded content according to applicable laws and regulations and accept full responsibility for any misuse or violation of copyright laws. 90 | 91 | The developers of this application shall not be held liable for any unauthorized or improper use of the downloaded content. Users are solely responsible for their actions and are encouraged to use this application responsibly and ethically. 92 | 93 |
94 | 95 | ### I hope you'll enjoy using Youtility as much as I enjoyed while making it. Thanks a lot 💖 96 | 97 | 98 | ``` 99 | ⠀⠀⢀⣀⣠⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣄⣀⡀⠀⠀ 100 | ⠀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀ 101 | ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀ 102 | ⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆ 103 | ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠈⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ 104 | ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⢈⣹⣿⣿⣿⣿⣿⣿⣿⡇ 105 | ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⢀⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ 106 | ⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇ 107 | ⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀ 108 | ⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀ 109 | ⠀⠀⠈⠉⠙⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠋⠉⠁⠀⠀ 110 | ``` 111 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import PyInstaller.__main__ 2 | 3 | PyInstaller.__main__.run([ 4 | 'youtility/main.py', 5 | '--onedir', 6 | '--w', 7 | '--icon="icon.ico"' 8 | ]) -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohankishore/Youtility/f67d91331a51d7135f9602b232db5ee8ca063f36/icon.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6==6.6.1 2 | PyQt6_Fluent_Widgets==1.5.1 3 | PyQt6_Frameless_Window==0.3.8 4 | PyQt6_sip==13.6.0 5 | PyQt_Fluent_Widgets==1.4.1 6 | pytube==15.0.0 7 | pyqtdarktheme==2.1.0 8 | yt-dlp==2024.5.27 9 | -------------------------------------------------------------------------------- /youtility/consts.py: -------------------------------------------------------------------------------- 1 | msgs = [ 2 | "Whoops! It seems the URL field is feeling empty, just like the void of space. Try filling it with a YouTube link!", 3 | "Looks like you forgot to give us a ticket to the YouTube show! Please enter a valid URL", 4 | "Uh-oh! The YouTube train can't leave the station without a proper URL ticket. Mind providing one?", 5 | "It's as quiet as a silent movie in here! Don't forget to drop a YouTube link for us to watch", 6 | "This is awkward... We were expecting a YouTube URL, but it seems to have taken a coffee break. Care to join the search?", 7 | "Houston, we have a problem! It appears the YouTube coordinates are missing. Please input a valid URL to proceed.", ] 8 | 9 | extension = "mp4" 10 | -------------------------------------------------------------------------------- /youtility/cut.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, pyqtSlot 5 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, \ 6 | QSpacerItem, QLabel, QFileDialog 7 | from pytube import YouTube 8 | from qfluentwidgets import (LineEdit, 9 | ListWidget, PushButton, MessageBox, ProgressBar, TextEdit) 10 | 11 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') 12 | 13 | 14 | class Stream(QObject): 15 | new_text = pyqtSignal(str) 16 | 17 | def write(self, text): 18 | self.new_text.emit(str(text)) 19 | 20 | def flush(self): 21 | pass 22 | 23 | 24 | class DownloaderThread(QThread): 25 | download_finished = pyqtSignal() 26 | new_text = pyqtSignal(str) 27 | 28 | def __init__(self, link, start_time, end_time, save_path): 29 | super().__init__() 30 | self.link = link 31 | self.start_time = start_time 32 | self.end_time = end_time 33 | self.save_path = save_path 34 | self.stream = Stream() 35 | self.stream.new_text.connect(self.handle_new_text) 36 | 37 | def run(self): 38 | link = self.link 39 | start_time = self.hhmmss_to_seconds(self.start_time) 40 | end_time = self.hhmmss_to_seconds(self.end_time) 41 | 42 | import yt_dlp 43 | from yt_dlp.utils import download_range_func 44 | 45 | yt_opts = { 46 | 'outtmpl': self.save_path, 47 | 'verbose': True, 48 | 'download_ranges': download_range_func(None, [(start_time, end_time)]), 49 | 'force_keyframes_at_cuts': True, 50 | } 51 | 52 | # Redirect stdout and stderr 53 | sys.stdout = self.stream 54 | sys.stderr = self.stream 55 | 56 | try: 57 | if start_time <= end_time: 58 | with yt_dlp.YoutubeDL(yt_opts) as ydl: 59 | ydl.download([link]) 60 | 61 | self.download_finished.emit() 62 | else: 63 | self.show_message_box("ERROR", "Start time cannot be greater than end time") 64 | except Exception as e: 65 | self.show_message_box("ERROR", f"Unexpected Error Occurred: {e}") 66 | 67 | # Reset stdout and stderr to their original state 68 | sys.stdout = sys.__stdout__ 69 | sys.stderr = sys.__stderr__ 70 | 71 | def handle_new_text(self, text): 72 | self.new_text.emit(text) 73 | 74 | def show_message_box(self, title, message): 75 | w = MessageBox(title, message) 76 | w.yesButton.setText('OK') 77 | w.exec() 78 | 79 | def hhmmss_to_seconds(self, hhmmss): 80 | h, m, s = map(int, hhmmss.split(':')) 81 | return h * 3600 + m * 60 + s 82 | 83 | def seconds_to_hhmmss(self, seconds): 84 | h = seconds // 3600 85 | m = (seconds % 3600) // 60 86 | s = seconds % 60 87 | return f"{h:02}:{m:02}:{s:02}" 88 | 89 | 90 | class CutVideos(QWidget): 91 | def __init__(self): 92 | super().__init__() 93 | 94 | spacer_item_small = QSpacerItem(0, 10) 95 | spacer_item_medium = QSpacerItem(0, 20) 96 | 97 | self.setObjectName("Cut") 98 | 99 | self.main_layout = QVBoxLayout() 100 | self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 101 | 102 | # YouTube Link Entry 103 | self.link_layout = QHBoxLayout() 104 | self.main_layout.addLayout(self.link_layout) 105 | self.link_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 106 | self.link_entry = LineEdit(self) 107 | self.link_entry.textChanged.connect(self.init_timers) 108 | self.link_entry.setPlaceholderText("Enter YouTube Video Link: ") 109 | self.link_layout.addWidget(self.link_entry) 110 | 111 | self.main_layout.addSpacerItem(spacer_item_small) 112 | 113 | self.time_layout = QHBoxLayout() 114 | self.main_layout.addLayout(self.time_layout) 115 | self.start_time = LineEdit() 116 | self.start_time.setPlaceholderText("Start Time") 117 | self.start_time.setText("00:00:00") 118 | self.end_time = LineEdit() 119 | self.end_time.setPlaceholderText("End Time") 120 | 121 | self.time_layout.addWidget(self.start_time) 122 | self.time_layout.addWidget(self.end_time) 123 | 124 | self.main_layout.addSpacerItem(spacer_item_medium) 125 | 126 | self.main_layout.addSpacerItem(spacer_item_medium) 127 | 128 | # Console Output 129 | self.console_output = TextEdit() 130 | self.console_output.setReadOnly(True) 131 | self.main_layout.addWidget(self.console_output) 132 | 133 | self.main_layout.addSpacerItem(spacer_item_medium) 134 | 135 | # Download Button 136 | self.button_layout = QHBoxLayout() 137 | self.main_layout.addLayout(self.button_layout) 138 | self.button_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 139 | self.download_button = PushButton() 140 | self.download_button.setText("Cut & Download") 141 | self.download_button.clicked.connect(self.download) 142 | self.button_layout.addWidget(self.download_button) 143 | 144 | self.loading_label = QLabel() 145 | self.main_layout.addWidget(self.loading_label) 146 | 147 | self.main_layout.addSpacerItem(spacer_item_medium) 148 | self.main_layout.addSpacerItem(spacer_item_medium) 149 | self.main_layout.addSpacerItem(spacer_item_medium) 150 | self.main_layout.addSpacerItem(spacer_item_medium) 151 | 152 | disclaimer = QLabel("*** This feature uses YT-DLP and requires ffmpeg and will take slightly longer time to render, and the quality is also NOT adjustable.") 153 | self.main_layout.addWidget(disclaimer) 154 | 155 | self.count_layout = QHBoxLayout() 156 | self.download_list_widget = ListWidget() 157 | self.count_layout.addWidget(self.download_list_widget) 158 | self.main_layout.addLayout(self.count_layout) 159 | 160 | self.setLayout(self.main_layout) 161 | self.caption_list = None 162 | 163 | def download(self): 164 | link = self.link_entry.text() 165 | start_time = (self.start_time.text()) 166 | end_time = (self.end_time.text()) 167 | 168 | save_path, _ = QFileDialog.getSaveFileName(self, "Save file", "cut_video") 169 | 170 | if save_path: 171 | thread = DownloaderThread(link, start_time, end_time, save_path) 172 | thread.download_finished.connect(self.show_download_finished_message) 173 | thread.new_text.connect(self.append_text) 174 | thread.start() 175 | 176 | @pyqtSlot(str) 177 | def append_text(self, text): 178 | logging.debug(f"Appending text: {text}") 179 | self.console_output.append(text) 180 | 181 | def init_timers(self): 182 | link = self.link_entry.text() 183 | try: 184 | import yt_dlp 185 | 186 | ydl_opts = {} 187 | length = "" 188 | 189 | video = YouTube(link) 190 | length = (video.length) 191 | length = self.seconds_to_hhmmss(length) 192 | self.end_time.setText(length) 193 | except Exception as e: 194 | logging.error(f"Failed to initialize timers: {e}", exc_info=True) 195 | 196 | def hhmmss_to_seconds(self, hhmmss): 197 | h, m, s = map(int, hhmmss.split(':')) 198 | return h * 3600 + m * 60 + s 199 | 200 | def seconds_to_hhmmss(self, seconds): 201 | h = seconds // 3600 202 | m = (seconds % 3600) // 60 203 | s = seconds % 60 204 | return f"{h:02}:{m:02}:{s:02}" 205 | 206 | def show_download_finished_message(self): 207 | self.loading_label.hide() 208 | -------------------------------------------------------------------------------- /youtility/downloader.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import subprocess 5 | import threading 6 | import sys 7 | import pytube.exceptions 8 | from PyQt6.QtCore import Qt, QThread, pyqtSignal 9 | from PyQt6.QtGui import QMovie 10 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QGroupBox, QComboBox, QFileDialog, QHBoxLayout, \ 11 | QSpacerItem, QLabel, QListWidgetItem, QProgressBar 12 | from pytube import YouTube 13 | from qfluentwidgets import (LineEdit, 14 | StrongBodyLabel, MessageBox, CheckBox, ListWidget, PushButton, ComboBox, ProgressBar) 15 | 16 | from consts import msgs, extension 17 | 18 | with open("resources/misc/config.json", "r") as themes_file: 19 | _themes = json.load(themes_file) 20 | 21 | theme_color = _themes["theme"] 22 | progressive = _themes["progressive"] 23 | 24 | 25 | class DownloaderThread(QThread): 26 | download_finished = pyqtSignal() 27 | progress_update = pyqtSignal(int) 28 | 29 | def __init__(self, link, quality, download_captions, copy_thumbnail_link, dwnld_list_widget, quality_menu, 30 | loading_label, main_window, save_path, mp3_only, audio_format, filename, caption_list=None, folder_path=None): 31 | super().__init__() 32 | self.link = link 33 | self.quality = quality 34 | self.download_captions = download_captions 35 | self.copy_thumbnail_link = copy_thumbnail_link 36 | self.caption_list = caption_list 37 | self.download_list_widget = dwnld_list_widget 38 | self.quality_menu = quality_menu 39 | self.loading_label = loading_label 40 | self.folder_path = folder_path 41 | self.save_path = save_path 42 | self.main_window = main_window 43 | self.mp3_only = mp3_only 44 | self.audio_format = audio_format 45 | self.file_size = 0 46 | self.filename = filename 47 | 48 | def run(self): 49 | caption_file_path = os.path.join(self.save_path, "captions.xml") 50 | 51 | self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 52 | youtube_client = YouTube(self.link, on_progress_callback=self.progress_function) 53 | title = youtube_client.title 54 | self.list_item = QListWidgetItem("Downloading: " + title) 55 | self.download_list_widget.addItem(self.list_item) 56 | 57 | if not self.mp3_only: 58 | youtube_client.streams.filter(file_extension=extension) 59 | 60 | # Get available streams for the video 61 | streams = youtube_client.streams.filter(progressive=False) 62 | 63 | # Get the selected quality option 64 | choice = self.quality_menu.currentIndex() 65 | stream = streams[choice] 66 | 67 | self.file_size = stream.file_size 68 | 69 | # Download the video 70 | stream.download(self.save_path) 71 | 72 | else: 73 | youtube_client.streams.filter(file_extension='mp4') # Use your specific extension 74 | stream = youtube_client.streams.filter(only_audio=True).first() 75 | self.file_size = stream.filesize # Set the file size 76 | stream.download(output_path=self.save_path, filename=self.filename + ".mp3") 77 | 78 | # Conversion to FLAC using ffmpeg 79 | if self.audio_format == "FLAC": 80 | input_file = os.path.join(self.save_path, self.filename + ".mp3").replace("\\", "/") 81 | output_file = os.path.join(self.save_path, self.filename + ".flac").replace("\\", "/") 82 | 83 | # Run the ffmpeg command to convert mp4 to flac 84 | ffmpeg_command = f'ffmpeg -i "{input_file}" "{output_file}"' 85 | try: 86 | subprocess.run(ffmpeg_command, shell=True, check=True) 87 | os.remove(input_file) 88 | except subprocess.CalledProcessError as e: 89 | print(f"Error during conversion: {e}") 90 | self.list_item.setText((title + " - Download failed during conversion")) 91 | 92 | self.download_finished.emit() 93 | self.list_item.setText((title + " - Downloaded")) 94 | 95 | if self.download_captions: 96 | # Download and save captions if enabled 97 | captions = youtube_client.captions 98 | language_dict = {} 99 | for caption in captions: 100 | language_name = caption.name.split(" - ")[0] 101 | language_code = caption.code.split(".")[0] 102 | 103 | if language_name not in language_dict: 104 | language_dict[language_name] = language_code 105 | 106 | lang_get = self.caption_list.currentText() 107 | lang = language_dict.get(lang_get) 108 | 109 | caption_dwnld = youtube_client.captions.get_by_language_code(lang) 110 | caption_dwnld = caption_dwnld.xml_captions 111 | 112 | # Save the caption file in the same directory as the video 113 | with open(caption_file_path, 'w', encoding="utf-8") as file: 114 | file.write(caption_dwnld) 115 | 116 | self.download_finished.emit() 117 | self.list_item.setText((title + " - Downloaded")) 118 | 119 | def progress_function(self, chunk, file_handle, bytes_remaining): 120 | current = ((self.file_size - bytes_remaining) / self.file_size) 121 | percent = int(current * 100) 122 | self.progress_update.emit(percent) 123 | 124 | 125 | class YoutubeVideo(QWidget): 126 | def __init__(self): 127 | super().__init__() 128 | 129 | spacer_item_small = QSpacerItem(0, 10) 130 | spacer_item_medium = QSpacerItem(0, 20) 131 | 132 | self.setObjectName("Video") 133 | self.audio_only_checkbox = "" 134 | 135 | self.main_layout = QVBoxLayout() 136 | self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 137 | 138 | # YouTube Link Entry 139 | self.link_layout = QHBoxLayout() 140 | self.main_layout.addLayout(self.link_layout) 141 | self.link_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 142 | self.link_entry = LineEdit(self) 143 | self.link_entry.textChanged.connect(self.get_quality) 144 | self.link_entry.setPlaceholderText("Enter YouTube Video Link: ") 145 | self.link_layout.addWidget(self.link_entry) 146 | 147 | self.main_layout.addSpacerItem(spacer_item_small) 148 | 149 | # Option menu for Quality 150 | self.quality_layout = QHBoxLayout() 151 | self.options_layout = QHBoxLayout() 152 | self.main_layout.addLayout(self.quality_layout) 153 | self.main_layout.addLayout(self.options_layout) 154 | self.quality_menu = ComboBox() 155 | self.quality_menu.setPlaceholderText("Video Quality (Enter link to view)") 156 | # self.quality_menu.addItems(["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p"]) 157 | self.quality_layout.addWidget(self.quality_menu) 158 | 159 | self.options_layout.addSpacerItem(spacer_item_medium) 160 | self.thumbnail_url_checkbox = CheckBox('Copy Thumbnail Link', self) 161 | 162 | self.audio_only_checkbox = CheckBox('Download Audio Only', self) 163 | self.audio_only_checkbox.stateChanged.connect(self.update_audio_format) 164 | 165 | self.captions_checkbox = CheckBox('Download Captions', self) 166 | self.captions_checkbox.stateChanged.connect(self.trigger_captions_list) 167 | 168 | self.options_group = QGroupBox("Additional Options") 169 | self.options_group_layout = QVBoxLayout(self.options_group) 170 | self.options_group_layout.addWidget(self.captions_checkbox) 171 | self.options_group_layout.addWidget(self.audio_only_checkbox) 172 | self.options_group_layout.addWidget(self.thumbnail_url_checkbox) 173 | self.options_group_layout.addSpacerItem(spacer_item_medium) 174 | self.options_layout.addWidget(self.options_group) 175 | 176 | self.main_layout.addSpacerItem(spacer_item_small) 177 | 178 | self.captions_layout = QHBoxLayout() 179 | self.captions_layout.addSpacerItem(spacer_item_medium) 180 | self.main_layout.addLayout(self.captions_layout) 181 | 182 | # Download Button 183 | self.button_layout = QHBoxLayout() 184 | self.main_layout.addLayout(self.button_layout) 185 | self.button_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 186 | self.download_button = PushButton() 187 | self.download_button.setText("Download") 188 | self.download_button.clicked.connect(self.download) 189 | self.button_layout.addWidget(self.download_button) 190 | 191 | # GIF Loading Screen 192 | self.gif_layout = QHBoxLayout() 193 | self.main_layout.addLayout(self.gif_layout) 194 | self.loading_label = QLabel() 195 | self.main_layout.addWidget(self.loading_label) 196 | 197 | # Progress Bar 198 | self.progress_bar = ProgressBar(self) 199 | self.main_layout.addWidget(self.progress_bar) 200 | self.progress_bar.hide() # Initially hide the progress bar 201 | 202 | # Progress Area 203 | self.count_layout = QHBoxLayout() 204 | # Create a QListWidget to display downloading status 205 | self.download_list_widget = ListWidget() 206 | self.count_layout.addWidget(self.download_list_widget) 207 | self.main_layout.addLayout(self.count_layout) 208 | 209 | self.setLayout(self.main_layout) 210 | self.caption_list = None # Define the caption_list attribute 211 | 212 | def update_audio_format(self): 213 | audio_formats = ["MP3", "FLAC"] 214 | if self.audio_only_checkbox.isChecked(): 215 | self.audio_format_select = ComboBox() 216 | self.audio_format_select.addItems(audio_formats) 217 | self.options_group_layout.addWidget(self.audio_format_select) 218 | else: 219 | self.audio_format_select.hide() 220 | 221 | def get_quality(self): 222 | url = self.link_entry.text() 223 | try: 224 | set_progressive = True 225 | if progressive == "True": 226 | set_progressive = True 227 | else: 228 | set_progressive = False 229 | youtube = pytube.YouTube(url) 230 | streams = youtube.streams.filter(progressive=set_progressive) 231 | self.quality_menu.clear() 232 | for stream in streams: 233 | self.quality_menu.addItem(stream.resolution) 234 | self.quality_menu.setCurrentText("360p") 235 | except pytube.exceptions.RegexMatchError: 236 | pass 237 | 238 | def trigger_captions_list(self): 239 | def trigger_captions(): 240 | if self.captions_checkbox.isChecked(): 241 | link = self.link_entry.text() 242 | if link == "": 243 | msg = random.choice(msgs) 244 | w = MessageBox( 245 | 'No URL Found', 246 | msg, 247 | self 248 | ) 249 | self.captions_checkbox.setChecked(False) 250 | w.yesButton.setText('Alright Genius 🤓') 251 | w.cancelButton.setText('Yeah let me try again 🤝') 252 | 253 | if w.exec(): 254 | pass 255 | 256 | else: 257 | try: 258 | youtube_client = YouTube(link) 259 | captions = youtube_client.captions 260 | language_names = [] 261 | language_dict = {} 262 | 263 | for caption in captions: 264 | language_name = caption.name.split(" - ")[0] # Extracting the main language name 265 | language_code = caption.code.split(".")[0] # Extracting the main language code 266 | 267 | if language_name not in language_dict: 268 | language_dict[language_name] = language_code 269 | 270 | if language_name not in language_names: 271 | language_names.append(language_name) 272 | 273 | self.caption_label = StrongBodyLabel('Caption Language', self) 274 | self.captions_layout.addWidget(self.caption_label) 275 | self.caption_list = ComboBox() 276 | self.caption_list.addItems(language_names) 277 | self.captions_layout.addWidget(self.caption_list) 278 | 279 | except pytube.exceptions.RegexMatchError: 280 | pass 281 | 282 | else: 283 | self.caption_list.hide() 284 | self.caption_label.hide() 285 | 286 | thread = threading.Thread(target=trigger_captions) 287 | thread.start() 288 | 289 | def download(self): 290 | link = self.link_entry.text() 291 | quality = self.quality_menu.currentText() 292 | download_captions = self.captions_checkbox.isChecked() 293 | copy_thumbnail_link = self.thumbnail_url_checkbox.isChecked() 294 | mp3_only = "" 295 | audio_format = "MP3" 296 | if self.audio_only_checkbox.isChecked(): 297 | mp3_only = True 298 | audio_format = self.audio_format_select.currentText() 299 | else: 300 | mp3_only = False 301 | title = "" 302 | try: 303 | yt = YouTube(link) 304 | title = yt.title 305 | except pytube.exceptions.RegexMatchError: 306 | title = "Untitled" 307 | 308 | # Open file dialog to get save path 309 | save_path, _ = QFileDialog.getSaveFileName(self, "Save file", title) 310 | if not save_path: 311 | return 312 | 313 | # Extract filename from save_path 314 | filename = os.path.basename(save_path) 315 | filename_without_extension, _ = os.path.splitext(filename) 316 | 317 | self.downloader_thread = DownloaderThread( 318 | link=link, 319 | quality=quality, 320 | download_captions=download_captions, 321 | copy_thumbnail_link=copy_thumbnail_link, 322 | save_path=os.path.dirname(save_path), # Pass the directory path here 323 | filename=filename_without_extension, # Pass the filename without extension 324 | loading_label=self.loading_label, 325 | dwnld_list_widget=self.download_list_widget, 326 | quality_menu=self.quality_menu, 327 | main_window=self, 328 | caption_list=self.caption_list, 329 | mp3_only=mp3_only, 330 | audio_format=audio_format 331 | ) 332 | self.downloader_thread.download_finished.connect(self.show_download_finished_message) 333 | self.downloader_thread.progress_update.connect(self.update_progress_bar) 334 | self.progress_bar.show() 335 | self.downloader_thread.start() 336 | 337 | def show_download_finished_message(self): 338 | self.loading_label.setText("Download Finished") 339 | #self.progress_bar.hide() 340 | 341 | def update_progress_bar(self, percent): 342 | self.progress_bar.setValue(percent) -------------------------------------------------------------------------------- /youtility/get_captions.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import xml.etree.ElementTree as ET 5 | 6 | import pytube.exceptions 7 | from PyQt6.QtCore import Qt, QThread, pyqtSignal 8 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QFileDialog, QHBoxLayout, \ 9 | QSpacerItem, QListWidgetItem 10 | from pytube import YouTube 11 | from qfluentwidgets import (LineEdit, 12 | StrongBodyLabel, MessageBox, ListWidget, PushButton) 13 | 14 | from consts import msgs, extension 15 | 16 | with open("resources/misc/config.json", "r") as themes_file: 17 | _themes = json.load(themes_file) 18 | 19 | def_sub_format = _themes["def_sub_format"] 20 | 21 | 22 | class DownloaderThread(QThread): 23 | download_finished = pyqtSignal() 24 | 25 | def __init__(self, link, dwnld_list_widget, 26 | main_window, save_path, ext, caption_list=None, folder_path=None): 27 | super().__init__() 28 | self.link = link 29 | self.caption_list = caption_list 30 | self.download_list_widget = dwnld_list_widget 31 | self.folder_path = folder_path 32 | self.save_path = save_path 33 | self.main_window = main_window 34 | self.ext = ext 35 | 36 | def run(self): 37 | def get_gif(): 38 | gifs = ["loading.gif", "loading_2.gif"] 39 | gif = random.choice(gifs) 40 | gif_path = "resources/misc/" + gif 41 | return gif_path 42 | 43 | caption_file_path = "" 44 | if self.ext == "XML": 45 | caption_file_path = os.path.join(self.save_path, "captions.xml") 46 | else: 47 | caption_file_path = os.path.join(self.save_path, "captions.srt") 48 | 49 | # Ensure the directory exists, create it if it doesn't 50 | os.makedirs(self.save_path, exist_ok=True) 51 | 52 | youtube_client = YouTube(self.link) 53 | title = youtube_client.title 54 | youtube_client.streams.filter(file_extension=extension) 55 | 56 | # Download and save captions if enabled 57 | captions = youtube_client.captions 58 | language_dict = {} 59 | self.list_item = QListWidgetItem( 60 | "Downloading: " + title + " :Captions") 61 | self.download_list_widget.addItem(self.list_item) 62 | for caption in captions: 63 | language_name = caption.name.split(" - ")[0] # Extracting the main language name 64 | language_code = caption.code.split(".")[0] # Extracting the main language code 65 | 66 | if language_name not in language_dict: 67 | language_dict[language_name] = language_code 68 | 69 | lang_get = self.caption_list.currentText() 70 | lang = language_dict.get(lang_get) 71 | 72 | caption_dwnld = youtube_client.captions.get_by_language_code(lang) 73 | 74 | caption_dwnld_xml = caption_dwnld.xml_captions 75 | if self.ext == "SRT": 76 | caption_dwnld_xml = self.convert_xml_string_to_srt(caption_dwnld_xml) 77 | 78 | with open(caption_file_path, 'w', encoding="utf-8") as file: 79 | file.write(caption_dwnld_xml) 80 | 81 | self.download_finished.emit() 82 | self.list_item.setText((title + " :Captions" + " - Downloaded")) 83 | 84 | def convert_xml_string_to_srt(self, xml_string): 85 | root = ET.fromstring(xml_string) 86 | 87 | srt_content = "" 88 | count = 1 89 | for child in root.findall('.//p'): 90 | start = int(child.attrib.get('t', 0)) / 1000 # Convert milliseconds to seconds 91 | duration = int(child.attrib.get('d', 0)) / 1000 # Convert milliseconds to seconds 92 | 93 | if start != 0 and duration != 0: 94 | start_time = self.convert_time_format(start) 95 | end_time = self.convert_time_format(start + duration) 96 | 97 | srt_content += str(count) + '\n' 98 | srt_content += start_time + ' --> ' + end_time + '\n' 99 | srt_content += child.text.strip() + '\n\n' 100 | 101 | count += 1 102 | 103 | return srt_content.strip() 104 | 105 | def convert_time_format(self, seconds): 106 | hours = seconds // 3600 107 | minutes = (seconds % 3600) // 60 108 | seconds = seconds % 60 109 | return "{:02d}:{:02d}:{:06.3f}".format(int(hours), int(minutes), seconds) 110 | 111 | 112 | class CaptionWidget(QWidget): 113 | def __init__(self): 114 | super().__init__() 115 | 116 | spacer_item_small = QSpacerItem(0, 10) 117 | spacer_item_medium = QSpacerItem(0, 20) 118 | 119 | self.setObjectName("Captions") 120 | self.caption_list = None 121 | 122 | self.main_layout = QVBoxLayout() 123 | self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 124 | 125 | # YouTube Link Entry 126 | self.link_layout = QHBoxLayout() 127 | self.main_layout.addLayout(self.link_layout) 128 | self.link_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 129 | self.link_entry = LineEdit(self) 130 | self.link_entry.textChanged.connect(self.trigger_captions_list) 131 | self.link_entry.setPlaceholderText("Enter YouTube Video Link: ") 132 | self.link_layout.addWidget(self.link_entry) 133 | 134 | self.main_layout.addSpacerItem(spacer_item_small) 135 | 136 | # Option menu for Quality 137 | self.quality_layout = QHBoxLayout() 138 | self.options_layout = QHBoxLayout() 139 | self.main_layout.addLayout(self.quality_layout) 140 | self.main_layout.addLayout(self.options_layout) 141 | self.ext_menu = QComboBox() 142 | self.ext_menu.addItems(["XML", "SRT"]) 143 | self.ext_menu.setCurrentText(def_sub_format) 144 | self.quality_layout.addWidget(self.ext_menu) 145 | self.options_layout.addSpacerItem(spacer_item_medium) 146 | 147 | self.main_layout.addSpacerItem(spacer_item_small) 148 | 149 | self.captions_layout = QHBoxLayout() 150 | self.captions_layout.addSpacerItem(spacer_item_medium) 151 | self.main_layout.addLayout(self.captions_layout) 152 | 153 | # Download Button 154 | self.button_layout = QVBoxLayout() 155 | self.main_layout.addLayout(self.button_layout) 156 | self.button_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 157 | 158 | self.download_button = PushButton() 159 | self.download_button.setText("Download") 160 | self.download_button.clicked.connect(self.download) 161 | self.convert_captions = PushButton() 162 | self.convert_captions.setText("Convert Existing XML Captions to SRT") 163 | self.convert_captions.clicked.connect(self.convert_existing_captions) 164 | self.button_layout.addWidget(self.download_button) 165 | self.button_layout.addWidget(self.convert_captions) 166 | 167 | # GIF Loading Screen 168 | self.gif_layout = QHBoxLayout() 169 | self.main_layout.addLayout(self.gif_layout) 170 | 171 | # Progress Area 172 | self.count_layout = QHBoxLayout() 173 | # Create a QListWidget to display downloading status 174 | self.download_list_widget = ListWidget() 175 | self.count_layout.addWidget(self.download_list_widget) 176 | self.main_layout.addLayout(self.count_layout) 177 | 178 | self.setLayout(self.main_layout) 179 | self.caption_list = None # Define the caption_list attribute 180 | 181 | def trigger_captions_list(self): 182 | link = self.link_entry.text() 183 | if link == "": 184 | msg = random.choice(msgs) 185 | w = MessageBox( 186 | 'No URL Found', 187 | msg, 188 | self 189 | ) 190 | w.yesButton.setText('Alright Genius 🤓') 191 | w.cancelButton.setText('Yeah let me try again 🤝') 192 | 193 | if w.exec(): 194 | pass 195 | 196 | else: 197 | try: 198 | youtube_client = YouTube(link) 199 | captions = youtube_client.captions 200 | language_names = [] 201 | language_dict = {} 202 | 203 | for caption in captions: 204 | language_name = caption.name.split(" - ")[0] # Extracting the main language name 205 | language_code = caption.code.split(".")[0] # Extracting the main language code 206 | 207 | if language_name not in language_dict: 208 | language_dict[language_name] = language_code 209 | 210 | if language_name not in language_names: 211 | language_names.append(language_name) 212 | 213 | self.caption_label = StrongBodyLabel('Caption Language', self) 214 | self.captions_layout.addWidget(self.caption_label) 215 | self.caption_list = QComboBox() 216 | self.caption_list.addItems(language_names) 217 | self.captions_layout.addWidget(self.caption_list) 218 | 219 | except pytube.exceptions.RegexMatchError: 220 | pass 221 | 222 | def download(self): 223 | 224 | link = self.link_entry.text() 225 | ext = self.ext_menu.currentText() 226 | 227 | title = "" 228 | try: 229 | yt = YouTube(link) 230 | title = yt.title 231 | except pytube.exceptions.RegexMatchError: 232 | title = "Untitled" 233 | 234 | # Open file dialog to get save path 235 | save_path, _ = QFileDialog.getSaveFileName(self, "Save file", title) 236 | 237 | self.downloader_thread = DownloaderThread( 238 | link=link, 239 | save_path=save_path, # Pass the save path here 240 | dwnld_list_widget=self.download_list_widget, 241 | main_window=self, 242 | caption_list=self.caption_list, 243 | ext=ext 244 | ) 245 | # self.downloader_thread.download_finished.connect(self.show_download_finished_message) 246 | self.downloader_thread.start() 247 | 248 | def convert_xml_string_to_srt(self, xml_string): 249 | root = ET.fromstring(xml_string) 250 | 251 | srt_content = "" 252 | count = 1 253 | for child in root.findall('.//p'): 254 | start = int(child.attrib.get('t', 0)) / 1000 # Convert milliseconds to seconds 255 | duration = int(child.attrib.get('d', 0)) / 1000 # Convert milliseconds to seconds 256 | 257 | if start != 0 and duration != 0: 258 | start_time = self.convert_time_format(start) 259 | end_time = self.convert_time_format(start + duration) 260 | 261 | srt_content += str(count) + '\n' 262 | srt_content += start_time + ' --> ' + end_time + '\n' 263 | srt_content += child.text.strip() + '\n\n' 264 | 265 | count += 1 266 | 267 | return srt_content.strip() 268 | 269 | def convert_time_format(self, seconds): 270 | hours = seconds // 3600 271 | minutes = (seconds % 3600) // 60 272 | seconds = seconds % 60 273 | return "{:02d}:{:02d}:{:06.3f}".format(int(hours), int(minutes), seconds) 274 | 275 | def convert_existing_captions(self): 276 | options = QFileDialog.Option.DontUseNativeDialog 277 | file_filter = ";XML Files (*.xml)" 278 | file_filter_save_xml = "Subtitle Files (*.srt);;" 279 | filename, _ = QFileDialog.getOpenFileName(self, 'Select SRT/XML File', '', file_filter, options=options) 280 | if filename: 281 | if ".xml" in filename: 282 | with open(filename, 'r', encoding='utf-8') as xml_file: 283 | xml_string = xml_file.read() 284 | srt_string = self.convert_xml_string_to_srt(xml_string=xml_string) 285 | save_filename, _ = QFileDialog.getSaveFileName(self, 'Save File', '', file_filter_save_xml, options=options) 286 | if save_filename: 287 | print('Selected save location:', save_filename) 288 | with open((save_filename+".srt"), 'w', encoding='utf-8') as file: 289 | file.write(srt_string) 290 | print('SRT file created and content written.') -------------------------------------------------------------------------------- /youtility/main.py: -------------------------------------------------------------------------------- 1 | # coding:utf-8 2 | import json 3 | import sys 4 | 5 | # import qdarktheme 6 | from PyQt6.QtCore import Qt, pyqtSignal, QEasingCurve, QUrl 7 | from PyQt6.QtGui import QIcon, QDesktopServices 8 | from PyQt6.QtWidgets import QApplication, QLabel, QHBoxLayout, QVBoxLayout, QFrame 9 | from qfluentwidgets import FluentIcon as FIF 10 | from qfluentwidgets import (NavigationInterface, NavigationItemPosition, MessageBox, 11 | isDarkTheme, setTheme, Theme, 12 | PopUpAniStackedWidget, setThemeColor) 13 | from qframelesswindow import FramelessWindow, TitleBar 14 | 15 | import cut 16 | import get_captions 17 | import playlist 18 | import settings 19 | from downloader import YoutubeVideo 20 | 21 | APP_NAME = "Youtility" 22 | 23 | with open("resources/misc/config.json", "r") as themes_file: 24 | _themes = json.load(themes_file) 25 | 26 | theme_color = _themes["theme"] 27 | progressive = _themes["progressive"] 28 | 29 | 30 | class StackedWidget(QFrame): 31 | """ Stacked widget """ 32 | 33 | currentChanged = pyqtSignal(int) 34 | 35 | def __init__(self, parent=None): 36 | super().__init__(parent=parent) 37 | self.hBoxLayout = QHBoxLayout(self) 38 | self.view = PopUpAniStackedWidget(self) 39 | 40 | self.hBoxLayout.setContentsMargins(0, 0, 0, 0) 41 | self.hBoxLayout.addWidget(self.view) 42 | 43 | self.view.currentChanged.connect(self.currentChanged) 44 | 45 | def addWidget(self, widget): 46 | """ add widget to view """ 47 | self.view.addWidget(widget) 48 | 49 | def widget(self, index: int): 50 | return self.view.widget(index) 51 | 52 | def setCurrentWidget(self, widget, popOut=False): 53 | if not popOut: 54 | self.view.setCurrentWidget(widget, duration=300) 55 | else: 56 | self.view.setCurrentWidget( 57 | widget, True, False, 200, QEasingCurve.Type.InQuad) 58 | 59 | def setCurrentIndex(self, index, popOut=False): 60 | self.setCurrentWidget(self.view.widget(index), popOut) 61 | 62 | 63 | class CustomTitleBar(TitleBar): 64 | """ Title bar with icon and title """ 65 | 66 | def __init__(self, parent): 67 | super().__init__(parent) 68 | # add window icon 69 | self.iconLabel = QLabel(self) 70 | self.iconLabel.setFixedSize(18, 18) 71 | self.hBoxLayout.insertSpacing(0, 10) 72 | self.hBoxLayout.insertWidget( 73 | 1, self.iconLabel, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) 74 | self.window().windowIconChanged.connect(self.setIcon) 75 | 76 | # add title label 77 | self.titleLabel = QLabel(self) 78 | self.hBoxLayout.insertWidget( 79 | 2, self.titleLabel, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) 80 | self.titleLabel.setObjectName('titleLabel') 81 | self.window().windowTitleChanged.connect(self.setTitle) 82 | 83 | def setTitle(self, title): 84 | self.titleLabel.setText(title) 85 | self.titleLabel.adjustSize() 86 | 87 | def setIcon(self, icon): 88 | self.iconLabel.setPixmap(QIcon(icon).pixmap(18, 18)) 89 | 90 | 91 | class Window(FramelessWindow): 92 | 93 | def __init__(self): 94 | super().__init__() 95 | self.setTitleBar(CustomTitleBar(self)) 96 | 97 | # use dark theme mode 98 | setTheme(Theme.DARK) 99 | 100 | # change the theme color 101 | setThemeColor(theme_color) 102 | 103 | self.hBoxLayout = QHBoxLayout(self) 104 | self.navigationBar = NavigationInterface(self) 105 | self.stackWidget = StackedWidget(self) 106 | 107 | # create sub interface 108 | self.videoInterface = YoutubeVideo() 109 | self.playlistInterface = playlist.YoutubePlaylist() 110 | self.cutInterface = cut.CutVideos() 111 | self.captionInterface = get_captions.CaptionWidget() 112 | self.settingsInterface = settings.SettingsPage() 113 | 114 | # initialize layout 115 | self.initLayout() 116 | 117 | # add items to navigation interface 118 | self.initNavigation() 119 | 120 | self.initWindow() 121 | 122 | def initLayout(self): 123 | self.hBoxLayout.setSpacing(0) 124 | self.hBoxLayout.setContentsMargins(0, 48, 0, 0) 125 | self.hBoxLayout.addWidget(self.navigationBar) 126 | self.hBoxLayout.addWidget(self.stackWidget) 127 | self.hBoxLayout.setStretchFactor(self.stackWidget, 1) 128 | 129 | def initNavigation(self): 130 | self.addSubInterface(self.videoInterface, FIF.VIDEO, 'Video', selectedIcon=FIF.VIDEO) 131 | self.addSubInterface(self.playlistInterface, FIF.FOLDER, 'Playlist', selectedIcon=FIF.FOLDER) 132 | self.addSubInterface(self.cutInterface, FIF.CUT, 'Cut & Download', selectedIcon=FIF.CUT) 133 | self.addSubInterface(self.captionInterface, QIcon("resources/icons/captions.svg"), 'Captions', 134 | selectedIcon=QIcon( 135 | "resources/icons/captions.svg")) 136 | self.addSubInterface(self.settingsInterface, FIF.SETTING, 'Settings', NavigationItemPosition.BOTTOM, 137 | FIF.SETTING) 138 | self.navigationBar.addItem( 139 | routeKey='About', 140 | icon=FIF.HELP, 141 | text='About', 142 | onClick=self.showMessageBox, 143 | selectable=False, 144 | position=NavigationItemPosition.BOTTOM, 145 | ) 146 | 147 | self.stackWidget.currentChanged.connect(self.onCurrentInterfaceChanged) 148 | self.navigationBar.setCurrentItem(self.videoInterface.objectName()) 149 | 150 | def initWindow(self): 151 | self.resize(1000, 600) 152 | self.setWindowIcon(QIcon('resources/icons/icon.png')) 153 | self.setWindowTitle('Youtility') 154 | self.setQss() 155 | 156 | def addSubInterface(self, interface, icon, text: str, position=NavigationItemPosition.TOP, selectedIcon=None): 157 | """ add sub interface """ 158 | self.stackWidget.addWidget(interface) 159 | self.navigationBar.addItem( 160 | routeKey=interface.objectName(), 161 | icon=icon, 162 | text=text, 163 | onClick=lambda: self.switchTo(interface), 164 | position=position, 165 | ) 166 | 167 | def setQss(self): 168 | color = 'dark' if isDarkTheme() else 'light' 169 | with open(f'resources/{color}/demo.qss', encoding='utf-8') as f: 170 | self.setStyleSheet(f.read()) 171 | 172 | def switchTo(self, widget): 173 | self.stackWidget.setCurrentWidget(widget) 174 | 175 | def onCurrentInterfaceChanged(self, index): 176 | widget = self.stackWidget.widget(index) 177 | self.navigationBar.setCurrentItem(widget.objectName()) 178 | 179 | def showMessageBox(self): 180 | text_for_about = f"Heya! it's Rohan, the creator of {APP_NAME}. I hope you've enjoyed using this app as much as I enjoyed making it." + "" + "\n" + "\n" \ 181 | "I'm a school student and I can't earn my own money LEGALLY. So any donations will be largely appreciated. Also, if you find any bugs / have any feature requests, you can open a Issue/ Pull Request in the Repo." \ 182 | "You can visit GitHub by pressing the button below. You can find Ko-Fi link there :) " + "\n" + "\n" + \ 183 | f"Once again, thank you for using {APP_NAME}. Please consider giving it a star ⭐ as it will largely motivate me to create more of such apps. Also do consider giving me a follow ;) " 184 | w = MessageBox( 185 | APP_NAME, 186 | text_for_about, 187 | self 188 | ) 189 | w.yesButton.setText('GitHub') 190 | w.cancelButton.setText('Return') 191 | 192 | if w.exec(): 193 | QDesktopServices.openUrl(QUrl("https://github.com/rohankishore/Youtility")) 194 | 195 | 196 | if __name__ == '__main__': 197 | app = QApplication(sys.argv) 198 | QApplication.setHighDpiScaleFactorRoundingPolicy( 199 | Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) 200 | 201 | #qdarktheme.enable_hi_dpi() 202 | w = Window() 203 | #qdarktheme.setup_theme("dark", custom_colors={"primary": theme_color}) 204 | w.show() 205 | sys.exit(app.exec()) 206 | -------------------------------------------------------------------------------- /youtility/playlist.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import re 5 | import subprocess 6 | 7 | import pytube.exceptions 8 | from PyQt6.QtCore import Qt, QThread, pyqtSignal 9 | from PyQt6.QtGui import QMovie 10 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QGroupBox, QComboBox, QFileDialog, QHBoxLayout, \ 11 | QSpacerItem, QLabel, QListWidgetItem 12 | from pytube import Playlist 13 | from qfluentwidgets import (LineEdit, 14 | CheckBox, ListWidget, TextEdit, PushButton, ComboBox) 15 | 16 | with open("resources/misc/config.json", "r") as themes_file: 17 | _themes = json.load(themes_file) 18 | 19 | progressive = _themes["progressive"] 20 | 21 | 22 | class DownloaderThread(QThread): 23 | download_finished = pyqtSignal() 24 | 25 | def __init__(self, link, quality, dwnld_list_widget, quality_menu, 26 | loading_label, main_window, save_path, progress_text, mp3_only, filename, audio_format, folder_path=None, 27 | copy_thumbnail_link=None): 28 | super().__init__() 29 | self.link = link 30 | self.quality = quality 31 | self.copy_thumbnail_link = copy_thumbnail_link 32 | self.download_list_widget = dwnld_list_widget 33 | self.quality_menu = quality_menu 34 | self.loading_label = loading_label 35 | self.folder_path = folder_path 36 | self.save_path = save_path 37 | self.progress_textbox = progress_text 38 | self.main_window = main_window 39 | self.mp3_only = mp3_only 40 | self.audio_format = audio_format 41 | self.filename = filename 42 | 43 | def run(self): 44 | def get_gif(): 45 | gifs = ["loading.gif", "loading_2.gif"] 46 | gif = random.choice(gifs) 47 | gif_path = "resources/misc/" + gif 48 | return gif_path 49 | 50 | self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 51 | self.loading_movie = QMovie(get_gif()) 52 | self.loading_label.setMovie(self.loading_movie) 53 | 54 | playlist = Playlist(self.link) 55 | playlist._video_regex = re.compile(r"\"url\":\"(/watch\?v=[\w-]*)") 56 | title = playlist.title 57 | self.list_item = QListWidgetItem( 58 | "Downloading: " + title) 59 | self.download_list_widget.addItem(self.list_item) 60 | 61 | choice = self.quality_menu.currentIndex() 62 | 63 | for video in playlist.videos: 64 | if not self.mp3_only: 65 | self.progress_textbox.append('Downloading: {} with URL: {}'.format(video.title, video.watch_url)) 66 | self.progress_textbox.append("\n") 67 | 68 | filtered_streams = video.streams.filter(type='video', progressive=False, file_extension='mp4') 69 | 70 | selected_stream = filtered_streams.filter(resolution=self.quality).first() 71 | 72 | selected_stream.download(output_path=self.save_path) 73 | 74 | self.progress_textbox.append('Downloaded: {}'.format(video.title)) 75 | 76 | elif self.mp3_only: 77 | self.progress_textbox.append( 78 | 'Downloading: {} with URL: {}'.format((video.title + " -audio"), video.watch_url)) 79 | self.progress_textbox.append("\n") 80 | filtered_streams = video.streams.filter(only_audio=True).first() 81 | filtered_streams.download(output_path=self.save_path, filename=(video.title + ".mp3")) 82 | self.progress_textbox.append('Downloaded: {}'.format(video.title)) 83 | 84 | if self.audio_format == "FLAC": 85 | input_file = os.path.join(self.save_path, (video.title + ".mp3")).replace("\\", "/") 86 | output_file = os.path.join(self.save_path, (video.title + ".flac")).replace("\\", "/") 87 | 88 | # Run the ffmpeg command to convert mp4 to flac 89 | ffmpeg_command = f'ffmpeg -i "{input_file}" "{output_file}"' 90 | try: 91 | subprocess.run(ffmpeg_command, shell=True, check=True) 92 | os.remove(input_file) 93 | except subprocess.CalledProcessError as e: 94 | print(f"Error during conversion: {e}") 95 | self.list_item.setText((title + " - Download failed during conversion")) 96 | 97 | self.download_finished.emit() 98 | self.list_item.setText((title + " - Downloaded")) 99 | 100 | 101 | class YoutubePlaylist(QWidget): 102 | def __init__(self): 103 | super().__init__() 104 | 105 | spacer_item_small = QSpacerItem(0, 10) 106 | spacer_item_medium = QSpacerItem(0, 20) 107 | 108 | self.setObjectName("Playlist") 109 | self.audio_format_choice = ComboBox() 110 | 111 | self.main_layout = QVBoxLayout() 112 | self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 113 | 114 | # YouTube Link Entry 115 | self.link_layout = QHBoxLayout() 116 | self.main_layout.addLayout(self.link_layout) 117 | self.link_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 118 | self.link_entry = LineEdit(self) 119 | self.link_entry.textChanged.connect(self.get_quality) 120 | self.link_entry.setPlaceholderText("Enter YouTube Playlist Link: ") 121 | self.link_layout.addWidget(self.link_entry) 122 | 123 | self.main_layout.addSpacerItem(spacer_item_small) 124 | 125 | # Option menu for Quality 126 | self.quality_layout = QHBoxLayout() 127 | self.options_layout = QHBoxLayout() 128 | self.main_layout.addLayout(self.quality_layout) 129 | self.main_layout.addLayout(self.options_layout) 130 | self.quality_menu = ComboBox() 131 | self.quality_menu.setPlaceholderText("Video Quality (Applies to all videos)") 132 | if progressive == "True": 133 | self.quality_menu.addItems(["720p", "480p", "360p", "240p", "144p"]) 134 | else: 135 | self.quality_menu.addItems(["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p", "144p"]) 136 | self.quality_layout.addWidget(self.quality_menu) 137 | self.options_layout.addSpacerItem(spacer_item_medium) 138 | self.thumbnail_url_checkbox = CheckBox('Copy Thumbnail URL', self) 139 | self.audio_only_checkbox = CheckBox('Download Audio Only', self) 140 | self.audio_only_checkbox.stateChanged.connect(self.audio_format_init) 141 | 142 | self.options_group = QGroupBox("Additional Options") 143 | self.options_group_layout = QVBoxLayout(self.options_group) 144 | self.options_group_layout.addWidget(self.thumbnail_url_checkbox) 145 | self.options_group_layout.addWidget(self.audio_only_checkbox) 146 | self.options_group_layout.addSpacerItem(spacer_item_medium) 147 | self.options_layout.addWidget(self.options_group) 148 | 149 | self.main_layout.addSpacerItem(spacer_item_small) 150 | 151 | self.captions_layout = QHBoxLayout() 152 | self.captions_layout.addSpacerItem(spacer_item_medium) 153 | self.main_layout.addLayout(self.captions_layout) 154 | 155 | # Download Button 156 | self.button_layout = QHBoxLayout() 157 | self.main_layout.addLayout(self.button_layout) 158 | self.button_layout.setAlignment(Qt.AlignmentFlag.AlignTop) 159 | self.download_button = PushButton() 160 | self.download_button.setText("Download") 161 | self.download_button.clicked.connect(self.download) 162 | self.button_layout.addWidget(self.download_button) 163 | 164 | self.loading_label = QLabel() 165 | self.main_layout.addWidget(self.loading_label) 166 | 167 | self.count_layout = QHBoxLayout() 168 | self.download_list_widget = ListWidget() 169 | self.download_list_text = TextEdit() 170 | self.download_list_text.setReadOnly(True) 171 | self.count_layout.addWidget(self.download_list_widget) 172 | self.count_layout.addWidget(self.download_list_text) 173 | self.main_layout.addLayout(self.count_layout) 174 | 175 | self.setLayout(self.main_layout) 176 | self.caption_list = None 177 | 178 | def audio_format_init(self): 179 | if self.audio_only_checkbox.isChecked(): 180 | audio_formats = ["MP3", "FLAC"] 181 | self.audio_format_choice = ComboBox() 182 | self.audio_format_choice.setCurrentText(_themes["def-audio-format"]) 183 | self.audio_format_choice.addItems(audio_formats) 184 | self.options_group_layout.addWidget(self.audio_format_choice) 185 | else: 186 | self.audio_format_choice.hide() 187 | 188 | def get_quality(self): 189 | url = self.link_entry.text() 190 | set_progressive = True 191 | try: 192 | youtube = pytube.YouTube(url) 193 | if progressive == "True": 194 | set_progressive = True 195 | else: 196 | set_progressive = False 197 | streams = youtube.streams.filter(progressive=set_progressive) 198 | self.quality_menu.clear() 199 | for stream in streams: 200 | self.quality_menu.addItem(stream.resolution) 201 | self.quality_menu.setCurrentText("360p") 202 | except pytube.exceptions.RegexMatchError: 203 | pass 204 | 205 | def download(self): 206 | link = self.link_entry.text() 207 | quality = self.quality_menu.currentText() 208 | mp3_only = "" 209 | if self.audio_only_checkbox.isChecked(): 210 | mp3_only = True 211 | else: 212 | mp3_only = False 213 | 214 | title = "" 215 | try: 216 | yt = Playlist(link) 217 | title = yt.title 218 | except pytube.exceptions.RegexMatchError: 219 | title = "Untitled" 220 | 221 | audio_format = self.audio_format_choice.currentText() 222 | save_path, _ = QFileDialog.getSaveFileName(self, "Save file", title) 223 | filename = os.path.basename(save_path) 224 | filename_without_extension, _ = os.path.splitext(filename) 225 | 226 | self.downloader_thread = DownloaderThread( 227 | link=link, 228 | quality=quality, 229 | save_path=save_path, # Pass the save path here 230 | loading_label=self.loading_label, 231 | dwnld_list_widget=self.download_list_widget, 232 | quality_menu=self.quality_menu, 233 | main_window=self, 234 | progress_text=self.download_list_text, 235 | mp3_only=mp3_only, 236 | audio_format=audio_format, 237 | filename=filename_without_extension 238 | ) 239 | self.downloader_thread.download_finished.connect(self.show_download_finished_message) 240 | self.downloader_thread.start() 241 | 242 | def show_download_finished_message(self): 243 | self.loading_label.hide() 244 | -------------------------------------------------------------------------------- /youtility/resources/dark/demo.qss: -------------------------------------------------------------------------------- 1 | Widget > QLabel { 2 | font: 24px 'Segoe UI', 'Microsoft YaHei'; 3 | } 4 | 5 | StackedWidget { 6 | border: 1px solid rgb(29, 29, 29); 7 | border-right: none; 8 | border-bottom: none; 9 | border-top-left-radius: 10px; 10 | background-color: rgb(39, 39, 39); 11 | } 12 | 13 | Window { 14 | background-color: rgb(32, 32, 32); 15 | } 16 | 17 | Widget > QLabel { 18 | color: white; 19 | } 20 | 21 | CustomTitleBar { 22 | background-color: transparent; 23 | } 24 | 25 | CustomTitleBar>QLabel#titleLabel { 26 | background: transparent; 27 | font: 13px 'Segoe UI'; 28 | padding: 0 10px; 29 | color: white; 30 | } 31 | 32 | MinimizeButton { 33 | qproperty-normalColor: white; 34 | qproperty-normalBackgroundColor: transparent; 35 | qproperty-hoverColor: white; 36 | qproperty-hoverBackgroundColor: rgba(255, 255, 255, 26); 37 | qproperty-pressedColor: white; 38 | qproperty-pressedBackgroundColor: rgba(255, 255, 255, 51) 39 | } 40 | 41 | MaximizeButton { 42 | qproperty-normalColor: white; 43 | qproperty-normalBackgroundColor: transparent; 44 | qproperty-hoverColor: white; 45 | qproperty-hoverBackgroundColor: rgba(255, 255, 255, 26); 46 | qproperty-pressedColor: white; 47 | qproperty-pressedBackgroundColor: rgba(255, 255, 255, 51) 48 | } 49 | 50 | CloseButton { 51 | qproperty-normalColor: white; 52 | qproperty-normalBackgroundColor: transparent; 53 | } 54 | -------------------------------------------------------------------------------- /youtility/resources/icons/README.md: -------------------------------------------------------------------------------- 1 | # Icon files for Youtility to work 2 | -------------------------------------------------------------------------------- /youtility/resources/icons/captions.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /youtility/resources/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohankishore/Youtility/f67d91331a51d7135f9602b232db5ee8ca063f36/youtility/resources/icons/icon.ico -------------------------------------------------------------------------------- /youtility/resources/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rohankishore/Youtility/f67d91331a51d7135f9602b232db5ee8ca063f36/youtility/resources/icons/icon.png -------------------------------------------------------------------------------- /youtility/resources/light/demo.qss: -------------------------------------------------------------------------------- 1 | Widget > QLabel { 2 | font: 24px 'Segoe UI', 'Microsoft YaHei'; 3 | } 4 | 5 | StackedWidget { 6 | border: 1px solid rgb(229, 229, 229); 7 | border-right: none; 8 | border-bottom: none; 9 | border-top-left-radius: 10px; 10 | background-color: rgb(249, 249, 249); 11 | } 12 | 13 | Window { 14 | background-color: rgb(243, 243, 243); 15 | } 16 | 17 | CustomTitleBar>QLabel#titleLabel { 18 | color: black; 19 | background: transparent; 20 | font: 13px 'Segoe UI'; 21 | padding: 0 10px 22 | } 23 | 24 | MinimizeButton { 25 | qproperty-normalColor: black; 26 | qproperty-normalBackgroundColor: transparent; 27 | qproperty-hoverColor: black; 28 | qproperty-hoverBackgroundColor: rgba(0, 0, 0, 26); 29 | qproperty-pressedColor: black; 30 | qproperty-pressedBackgroundColor: rgba(0, 0, 0, 51) 31 | } 32 | 33 | 34 | MaximizeButton { 35 | qproperty-normalColor: black; 36 | qproperty-normalBackgroundColor: transparent; 37 | qproperty-hoverColor: black; 38 | qproperty-hoverBackgroundColor: rgba(0, 0, 0, 26); 39 | qproperty-pressedColor: black; 40 | qproperty-pressedBackgroundColor: rgba(0, 0, 0, 51) 41 | } 42 | 43 | CloseButton { 44 | qproperty-normalColor: black; 45 | qproperty-normalBackgroundColor: transparent; 46 | } 47 | -------------------------------------------------------------------------------- /youtility/resources/misc/config.json: -------------------------------------------------------------------------------- 1 | {"theme": "#c69ef7", "def_sub_format": "SRT", "progressive": "True"} 2 | -------------------------------------------------------------------------------- /youtility/settings.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from PyQt6.QtWidgets import QWidget, QVBoxLayout, QGroupBox, QPushButton, QComboBox, QSpacerItem 4 | from qfluentwidgets import (LineEdit, StrongBodyLabel, MessageBox, CheckBox, ComboBox) 5 | 6 | with open("resources/misc/config.json", "r") as themes_file: 7 | _themes = json.load(themes_file) 8 | 9 | 10 | class SettingsPage(QWidget): 11 | def __init__(self): 12 | super().__init__() 13 | self.setObjectName("Settings") 14 | self.initUI() 15 | 16 | def initUI(self): 17 | 18 | spacer_item_small = QSpacerItem(0, 10) 19 | spacer_item_medium = QSpacerItem(0, 20) 20 | 21 | layout = QVBoxLayout() 22 | layout.addStretch() 23 | 24 | theming_group = QGroupBox("Theming") 25 | theming_layout = QVBoxLayout(theming_group) 26 | layout.addWidget(theming_group) 27 | 28 | pref_group = QGroupBox("Preferences") 29 | pref_layout = QVBoxLayout(pref_group) 30 | layout.addWidget(pref_group) 31 | 32 | # Theme Color Label 33 | theme_color_label = StrongBodyLabel("Theme Color: ", self) 34 | theming_layout.addWidget(theme_color_label) 35 | 36 | # Theme Color Line Edit 37 | self.theme_color_line_edit = LineEdit() 38 | self.theme_color_line_edit.setText(_themes["theme"]) 39 | theming_layout.addWidget(self.theme_color_line_edit) 40 | 41 | def_sub_format_label = StrongBodyLabel("Default Subtitle Format: ", self) 42 | pref_layout.addWidget(def_sub_format_label) 43 | self.def_sub_format = ComboBox() 44 | self.def_sub_format.addItems(["SRT", "XML"]) 45 | self.def_sub_format.setCurrentText(_themes["def_sub_format"]) 46 | pref_layout.addWidget(self.def_sub_format) 47 | 48 | pref_layout.addSpacerItem(spacer_item_medium) 49 | 50 | self.set_progressive = CheckBox() 51 | self.set_progressive.setText("Allow higher res downloads (audio may be missing): ") 52 | if _themes["progressive"] == "False": 53 | self.set_progressive.setChecked(True) 54 | else: 55 | pass 56 | pref_layout.addWidget(self.set_progressive) 57 | 58 | # Apply Button 59 | self.apply_button = QPushButton("Apply") 60 | self.apply_button.clicked.connect(self.save_json) 61 | layout.addWidget(self.apply_button) 62 | 63 | self.setLayout(layout) 64 | 65 | def save_json(self): 66 | progressive_state = "True" 67 | if self.set_progressive.isChecked(): 68 | progressive_state = "False" 69 | else: 70 | progressive_state = "True" 71 | 72 | _themes["theme"] = self.theme_color_line_edit.text() 73 | _themes["def_sub_format"] = self.def_sub_format.currentText() 74 | _themes["progressive"] = progressive_state 75 | 76 | with open("resources/misc/config.json", "w") as json_file: 77 | json.dump(_themes, json_file) 78 | 79 | w = MessageBox( 80 | 'Settings Applied!', 81 | "Restart Youtility to view the changes", 82 | self 83 | ) 84 | w.yesButton.setText('Cool 🤝') 85 | w.cancelButton.setText('Extra Cool 😘') 86 | 87 | if w.exec(): 88 | pass 89 | --------------------------------------------------------------------------------