diff --git a/com.jeffser.Alpaca.json b/com.jeffser.Alpaca.json index f089fc5..67bbc84 100644 --- a/com.jeffser.Alpaca.json +++ b/com.jeffser.Alpaca.json @@ -85,6 +85,20 @@ } ] }, + { + "name": "python3-pytube", + "buildsystem": "simple", + "build-commands": [ + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pytube\" --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/51/64/bcf8632ed2b7a36bbf84a0544885ffa1d0b4bcf25cc0903dba66ec5fdad9/pytube-15.0.0-py3-none-any.whl", + "sha256": "07b9904749e213485780d7eb606e5e5b8e4341aa4dccf699160876da00e12d78" + } + ] + }, { "name": "ollama", "buildsystem": "simple", diff --git a/src/alpaca.gresource.xml b/src/alpaca.gresource.xml index 20aaea4..e2c88ca 100644 --- a/src/alpaca.gresource.xml +++ b/src/alpaca.gresource.xml @@ -23,6 +23,7 @@ icons/brain-augemnted-symbolic.svg icons/chain-link-loose-symbolic.svg icons/document-text-symbolic.svg + icons/play-symbolic.svg window.ui gtk/help-overlay.ui diff --git a/src/dialogs.py b/src/dialogs.py index 62dc281..3939578 100644 --- a/src/dialogs.py +++ b/src/dialogs.py @@ -182,8 +182,8 @@ def remove_attached_file_response(self, dialog, task, button): def remove_attached_file(self, button): dialog = Adw.AlertDialog( - heading=_("Remove File"), - body=_("Are you sure you want to remove file?"), + heading=_("Remove Attachment"), + body=_("Are you sure you want to remove attachment?"), close_response="cancel" ) dialog.add_response("cancel", _("Cancel")) @@ -290,3 +290,40 @@ def attach_file_response(self, file_dialog, result): def attach_file(self, filter): file_dialog = Gtk.FileDialog(default_filter=filter) file_dialog.open(self, None, lambda file_dialog, result: attach_file_response(self, file_dialog, result)) + + +# YouTube caption | + +def youtube_caption_response(self, dialog, task, video_url, caption_drop_down): + if dialog.choose_finish(task) == "accept": + buffer = self.message_text_view.get_buffer() + text = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), False).replace(video_url, "") + buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) + buffer.insert(buffer.get_start_iter(), text, len(text)) + self.attach_file("{}&caption_lang={}".format(video_url, caption_drop_down.get_selected_item().get_string().split(' | ')[1]), 'youtube') + +def youtube_caption(self, video_title, video_url, captions): + if len(captions) == 0: + self.show_toast("error", 9, self.main_overlay) + return + caption_list = Gtk.StringList() + for caption in captions: caption_list.append("{} | {}".format(caption.name, caption.code)) + caption_drop_down = Gtk.DropDown( + enable_search=True, + model=caption_list + ) + dialog = Adw.AlertDialog( + heading=_("Attach YouTube Video"), + body=_("{}\n\nPlease select a transcript to include").format(video_title), + extra_child=caption_drop_down, + close_response="cancel" + ) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("accept", _("Accept")) + dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) + dialog.choose( + parent = self, + cancellable = None, + callback = lambda dialog, task, video_url = video_url, caption_drop_down = caption_drop_down: youtube_caption_response(self, dialog, task, video_url, caption_drop_down) + ) + diff --git a/src/icons/play-symbolic.svg b/src/icons/play-symbolic.svg new file mode 100644 index 0000000..64cb6ce --- /dev/null +++ b/src/icons/play-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/src/window.py b/src/window.py index 68e69e4..22d80ab 100644 --- a/src/window.py +++ b/src/window.py @@ -22,6 +22,7 @@ gi.require_version('GtkSource', '5') gi.require_version('GdkPixbuf', '2.0') from gi.repository import Adw, Gtk, Gdk, GLib, GtkSource, Gio, GdkPixbuf import json, requests, threading, os, re, base64, sys, gettext, locale, webbrowser, subprocess, uuid, shutil, tarfile, tempfile #, docx +from pytube import YouTube from time import sleep from io import BytesIO from PIL import Image @@ -122,7 +123,9 @@ class AlpacaWindow(Adw.ApplicationWindow): _("Cannot open image"), _("Cannot delete chat because it's the only one left"), _("There was an error with the local Ollama instance, so it has been reset"), - _("Image recognition is only available on specific models") + _("Image recognition is only available on specific models"), + _("This video does not have any transcriptions"), + _("This video is not available") ], "info": [ _("Please select a model before chatting"), @@ -182,10 +185,15 @@ class AlpacaWindow(Adw.ApplicationWindow): can_use_images = self.verify_if_image_can_be_used() for name, content in self.attachments.items(): if content["type"] == 'image' and can_use_images: attached_images.append(name) - else: attached_files[name] = content['type'] + else: + if content["type"] == 'youtube': + attached_files[content['path']] = content['type'] + else: + attached_files[name] = content['type'] if not os.path.exists(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id)): os.makedirs(os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id)) - shutil.copy(content['path'], os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name)) + if content["type"] != 'youtube': + shutil.copy(content['path'], os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name)) content["button"].get_parent().remove(content["button"]) self.attachments = {} self.attachment_box.set_visible(False) @@ -420,7 +428,10 @@ class AlpacaWindow(Adw.ApplicationWindow): buffer = self.file_preview_text_view.get_buffer() buffer.delete(buffer.get_start_iter(), buffer.get_end_iter()) buffer.insert(buffer.get_start_iter(), content, len(content)) - self.file_preview_dialog.set_title(os.path.basename(file_path)) + if file_type == 'youtube': + self.file_preview_dialog.set_title(YouTube(file_path).title) + else: + self.file_preview_dialog.set_title(os.path.basename(file_path)) self.file_preview_dialog.present(self) def convert_history_to_ollama(self): @@ -431,7 +442,10 @@ class AlpacaWindow(Adw.ApplicationWindow): del new_message['files'] new_message['content'] = '' for name, file_type in message['files'].items(): - file_path = os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name) + if file_type == 'youtube': + file_path = name + else: + file_path = os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name) file_data = self.get_content_of_file(file_path, file_type) if file_data: new_message['content'] += f"```[{name}]\n{file_data}\n```" new_message['content'] += message['content'] @@ -531,11 +545,15 @@ class AlpacaWindow(Adw.ApplicationWindow): child=file_container ) for name, file_type in files.items(): - shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}" + if file_type == 'youtube': + yt = YouTube(name) + shown_name=yt.title[:20] + (yt.title[20:] and '..') + else: + shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}" button_content = Adw.ButtonContent( label=shown_name, - icon_name="document-text-symbolic" + icon_name="play-symbolic" if file_type=='youtube' else "document-text-symbolic" ) button = Gtk.Button( vexpand=False, @@ -545,7 +563,11 @@ class AlpacaWindow(Adw.ApplicationWindow): tooltip_text=name, child=button_content ) - button.connect("clicked", lambda button, file_path=os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name), file_type=file_type: self.preview_file(file_path, file_type)) + if file_type == 'youtube': + file_path = name + else: + file_path = os.path.join(self.data_dir, "chats", self.chats['selected_chat'], id, name) + button.connect("clicked", lambda button, file_path=file_path, file_type=file_type: self.preview_file(file_path, file_type)) file_container.append(button) message_box.append(file_scroller) @@ -1112,7 +1134,7 @@ class AlpacaWindow(Adw.ApplicationWindow): self.verify_connection() def get_content_of_file(self, file_path, file_type): - if not os.path.exists(file_path): return None + if file_type != 'youtube' and not os.path.exists(file_path): return None if file_type == 'image': try: with Image.open(file_path) as img: @@ -1141,6 +1163,12 @@ class AlpacaWindow(Adw.ApplicationWindow): for i, page in enumerate(reader.pages): text += f"\n- Page {i}\n{page.extract_text()}\n" return text + elif file_type == 'youtube': + yt = YouTube(file_path) + text = "{}\n{}\n\n".format(yt.title, yt.author) + for event in yt.captions[file_path.split('&caption_lang=')[1]].json_captions['events']: + text += "{}\n".format(event['segs'][0]['utf8'].replace('\n', '\\n')) + return text #elif file_type == 'docx': #document = docx.Document(file_path) #if len(document.paragraphs) == 0: return None @@ -1155,17 +1183,23 @@ class AlpacaWindow(Adw.ApplicationWindow): if len(self.attachments) == 0: self.attachment_box.set_visible(False) def attach_file(self, file_path, file_type): - name = self.generate_numbered_name(os.path.basename(file_path), self.attachments.keys()) + if file_type == "youtube": + name = YouTube(file_path).title + else: + name = self.generate_numbered_name(os.path.basename(file_path), self.attachments.keys()) content = self.get_content_of_file(file_path, file_type) if content: - shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}" - + if file_type == "youtube": + shown_name=name[:20] + (name[20:] and '..') + else: + shown_name='.'.join(name.split(".")[:-1])[:20] + (name[20:] and '..') + f".{name.split('.')[-1]}" button_content = Adw.ButtonContent( label=shown_name, icon_name={ "image": "image-x-generic-symbolic", "plain_text": "document-text-symbolic", "pdf": "document-text-symbolic", + "youtube": "play-symbolic", #"docx": "document-text-symbolic" }[file_type] ) @@ -1195,6 +1229,22 @@ class AlpacaWindow(Adw.ApplicationWindow): elif action_name == 'export_chat': self.export_chat(chat_name) + def text_received(self, clipboard, result): + text = clipboard.read_text_finish(result) + #Check if text is a Youtube URL + youtube_regex = re.compile( + r'(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/' + r'(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})') + if youtube_regex.match(text): + try: + yt = YouTube(text) + dialogs.youtube_caption(self, yt.title, text, yt.captions) + except Exception as e: + self.show_toast("error", 10, self.main_overlay) + + def on_clipboard_paste(self, textview): + clipboard = Gdk.Display.get_default().get_clipboard() + clipboard.read_text_async(None, self.text_received) def __init__(self, **kwargs): super().__init__(**kwargs) @@ -1214,6 +1264,7 @@ class AlpacaWindow(Adw.ApplicationWindow): self.get_application().create_action('delete_chat', self.chat_actions) self.get_application().create_action('rename_chat', self.chat_actions) self.get_application().create_action('export_chat', self.chat_actions) + self.message_text_view.connect("paste-clipboard", self.on_clipboard_paste) self.add_chat_button.connect("clicked", lambda button : self.new_chat()) self.attachment_button.connect("clicked", lambda button, file_filter=self.file_filter_attachments: dialogs.attach_file(self, file_filter)) self.create_model_name.get_delegate().connect("insert-text", self.check_alphanumeric)