├── 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 | 
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 |
12 | [](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 | 
38 |
39 | 
40 |
41 | 
42 |
43 | 
44 |
45 | 
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 |
--------------------------------------------------------------------------------