diff --git a/browser_extension/background.js b/browser_extension/background.js index df26240..24e7276 100644 --- a/browser_extension/background.js +++ b/browser_extension/background.js @@ -1,9 +1,8 @@ -let startTime; +let startTime = Date.now(); async function initializeStartTime() { const data = await chrome.storage.local.get(['startTime']); if (!data.startTime) { - startTime = Date.now(); chrome.storage.local.set({ startTime }); } else { startTime = data.startTime; @@ -12,10 +11,23 @@ async function initializeStartTime() { initializeStartTime(); +chrome.runtime.onStartup.addListener(function () { + startTime = Date.now(); + chrome.storage.local.set({ startTime }); +}); + chrome.runtime.onInstalled.addListener(function (details) { if (details.reason === 'install') { chrome.storage.sync.set({ enabled: true, cookieTransferFile: true, cookieTransferVideo: true }); } + + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: "download-with-varia", + title: "Download with Varia", + contexts: ["link", "image", "video", "audio"] + }); + }); }); chrome.downloads.onCreated.addListener(function (downloadItem) { @@ -121,7 +133,7 @@ async function sendToAria2(downloadItem, downloadType) { const data = await response.json(); console.log("Aria2 response:", data); - if (downloadType === "file" && data.result) { + if (downloadType === "file" && data.result && downloadItem.id) { chrome.downloads.cancel(downloadItem.id); } @@ -167,4 +179,13 @@ async function getCookies(downloadUrl, downloadType) { return btoa(json); } -} \ No newline at end of file +} + +chrome.contextMenus.onClicked.addListener(function (info, tab) { + if (info.menuItemId === "download-with-varia") { + let downloadUrl = info.linkUrl || info.srcUrl; + if (downloadUrl) { + sendToAria2({ url: downloadUrl }, "file"); + } + } +}); \ No newline at end of file diff --git a/browser_extension/manifest-chromium.json b/browser_extension/manifest-chromium.json index 501a3a3..08bf6a1 100644 --- a/browser_extension/manifest-chromium.json +++ b/browser_extension/manifest-chromium.json @@ -14,7 +14,8 @@ "downloads", "storage", "tabs", - "cookies" + "cookies", + "contextMenus" ], "host_permissions": [ "" diff --git a/browser_extension/manifest-firefox.json b/browser_extension/manifest-firefox.json index 5550184..8be1452 100644 --- a/browser_extension/manifest-firefox.json +++ b/browser_extension/manifest-firefox.json @@ -15,7 +15,8 @@ "storage", "", "tabs", - "cookies" + "cookies", + "contextMenus" ], "manifest_version": 3, "background": { diff --git a/browser_extension/manifest.json b/browser_extension/manifest.json new file mode 100644 index 0000000..08bf6a1 --- /dev/null +++ b/browser_extension/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "Varia Integrator", + "description": "Route all downloads to Varia if it's running.", + "version": "1.5.4", + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + }, + "action": { + "default_popup": "popup.html" + }, + "permissions": [ + "downloads", + "storage", + "tabs", + "cookies", + "contextMenus" + ], + "host_permissions": [ + "" + ], + "manifest_version": 3, + "background": { + "service_worker": "background.js" + } +} \ No newline at end of file diff --git a/src/download/actionrow.py b/src/download/actionrow.py index ecf9e98..d9f5932 100644 --- a/src/download/actionrow.py +++ b/src/download/actionrow.py @@ -3,7 +3,8 @@ gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, Pango, GLib, Gio from stringstorage import gettext as _ -from download.thread import DownloadThread +from download.thread import DownloadThread, get_category_for_filename +from urllib.parse import urlparse from download.details import show_download_details_dialog import json import os @@ -20,6 +21,26 @@ def on_download_clicked(button, self, entry, downloadname, download, mode, video video_options = json.loads(video_options) if url: + is_torrent_url = url.startswith("magnet:") or url.lower().split('?')[0].endswith(".torrent") + + if self.appconf.get("automatic_sorting_enabled", "0") == "1" and not is_torrent_url: + name_to_check = downloadname + if not name_to_check: + try: + parsed_url = urlparse(url) + name_to_check = os.path.basename(parsed_url.path) + except: + pass + category = get_category_for_filename(name_to_check) + if category: + if not dir.endswith(category) and os.path.basename(dir) != category: + dir = os.path.join(dir, category) + try: + if not os.path.exists(dir): + os.makedirs(dir) + except: + pass + if downloadname: download_item = create_actionrow(self, downloadname) @@ -72,6 +93,14 @@ def create_actionrow(self, filename): percentage_label.set_margin_end(4) percentage_and_filename_box.append(percentage_label) + non_resumable_badge = Gtk.Label() + non_resumable_badge.set_markup("⚠️ " + _("No Resume") + "") + non_resumable_badge.set_margin_end(6) + non_resumable_badge.set_valign(Gtk.Align.CENTER) + non_resumable_badge.set_visible(False) + non_resumable_badge.set_tooltip_text(_("This download does not support resuming. If paused, it will restart from the beginning.")) + percentage_and_filename_box.append(non_resumable_badge) + filename_label = Gtk.Label(label=filename) filename_label.set_ellipsize(Pango.EllipsizeMode.END) filename_label.set_halign(Gtk.Align.START) @@ -136,6 +165,7 @@ def create_actionrow(self, filename): download_item.stop_button = stop_button download_item.filename_label = filename_label download_item.info_button = info_button + download_item.non_resumable_badge = non_resumable_badge return download_item @@ -144,7 +174,24 @@ def on_pause_clicked(button, self, pause_button, download_item, force_pause, run download_item.download_thread.resume() else: - download_item.download_thread.pause() + if download_item.download_thread.download_details.get('resumable') == _("No") and not force_pause: + dialog = Adw.AlertDialog() + dialog.set_heading(_("Pause Non-Resumable Download?")) + dialog.set_body(_("This download does not support resuming. If you pause it, the download will restart from 0% when you resume it. Are you sure you want to pause?")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("pause", _("Pause Anyway")) + dialog.set_response_appearance("pause", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + + def handle_response(d, response): + if response == "pause": + download_item.download_thread.pause() + + dialog.connect("response", handle_response) + dialog.present(self) + else: + download_item.download_thread.pause() def on_stop_clicked(button, self, download_item): download_item.download_thread.stop() diff --git a/src/download/details.py b/src/download/details.py index f3dbfbf..d7ba9da 100644 --- a/src/download/details.py +++ b/src/download/details.py @@ -77,6 +77,11 @@ def show_download_details_dialog(button, self, download_item): actionrow_download_download_speed.add_suffix(label_download_speed) group_1.add(actionrow_download_download_speed) + actionrow_download_resumable = Adw.ActionRow(title=_("Supports Resume"), tooltip_text=_("Supports Resume") + ": " + download_item.download_thread.download_details.get('resumable', '...')) + label_resumable = Gtk.Label(label=download_item.download_thread.download_details.get('resumable', '...')) + actionrow_download_resumable.add_suffix(label_resumable) + group_1.add(actionrow_download_resumable) + # Peers scrolled_window = None @@ -190,6 +195,7 @@ def update_details(): label_percentage.set_text(details.get('percentage', '')) label_remaining.set_text(details.get('remaining', '')) label_download_speed.set_text(details.get('download_speed', '')) + label_resumable.set_text(details.get('resumable', '')) if self.details_dialog_message_actionrow_added == False and download_item and download_item.download_thread.download_message_shown and download_item.download_thread.download_details.get('message', '') != '': label_download_message.set_text(download_item.download_thread.download_details.get('message', '')) diff --git a/src/download/manage_downloads.py b/src/download/manage_downloads.py index 2595823..27db97c 100644 --- a/src/download/manage_downloads.py +++ b/src/download/manage_downloads.py @@ -2,7 +2,8 @@ from stringstorage import gettext as _ import gi gi.require_version('Gtk', '4.0') -from gi.repository import GLib +gi.require_version('Adw', '1') +from gi.repository import GLib, Adw def pause_all(self, called_by_scheduler): if len(self.downloads) > 0: @@ -11,8 +12,33 @@ def pause_all(self, called_by_scheduler): download_thread.resume(called_by_scheduler) else: - for download_thread in self.downloads: - download_thread.pause(called_by_scheduler) + # Check if any active download is not resumable + active_non_resumable_downloads = [] + for d in self.downloads: + if not d.is_complete and not d.cancelled and not d.paused: + if d.download_details.get('resumable') == _("No"): + active_non_resumable_downloads.append(d) + + if active_non_resumable_downloads and not called_by_scheduler: + dialog = Adw.AlertDialog() + dialog.set_heading(_("Pause Non-Resumable Downloads?")) + dialog.set_body(_("One or more active downloads do not support resuming. If you pause them, their progress will be lost and they will restart from 0% when resumed. Are you sure you want to pause all?")) + dialog.add_response("cancel", _("Cancel")) + dialog.add_response("pause", _("Pause All Anyway")) + dialog.set_response_appearance("pause", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + + def handle_response(d, response): + if response == "pause": + for download_thread in self.downloads: + download_thread.pause(called_by_scheduler) + + dialog.connect("response", handle_response) + dialog.present(self) + else: + for download_thread in self.downloads: + download_thread.pause(called_by_scheduler) def stop_all(self, app, variaapp): while self.downloads != []: diff --git a/src/download/thread.py b/src/download/thread.py index acf28a1..f313b4d 100644 --- a/src/download/thread.py +++ b/src/download/thread.py @@ -14,10 +14,55 @@ import math import subprocess +def get_category_for_filename(filename): + if not filename: + return None + ext = filename.split('.')[-1].lower() + + categories = { + 'Compressed': ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'z', 'iso', 'dmg'], + 'Pictures': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tiff'], + 'Documents': ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf', 'txt', 'csv'], + 'Video': ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg'], + 'Music': ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'wma'] + } + + for category, extensions in categories.items(): + if ext in extensions: + return category + return None + class DownloadThread(threading.Thread): def __init__(self, app, url, actionrow, downloadname, download, mode, video_options, paused, dir, percentage): threading.Thread.__init__(self) self.api = app.api + + if download is not None: + dir = str(getattr(download, "dir", dir)) + + else: + is_torrent = False + if url and (url.startswith("magnet:") or url.lower().split('?')[0].endswith(".torrent")): + is_torrent = True + + if app.appconf.get("automatic_sorting_enabled", "0") == "1" and not is_torrent: + name_to_check = downloadname + if not name_to_check and url: + try: + parsed_url = urlparse(url) + name_to_check = os.path.basename(parsed_url.path) + except: + pass + category = get_category_for_filename(name_to_check) + if category: + if not dir.endswith(category) and os.path.basename(dir) != category: + dir = os.path.join(dir, category) + try: + if not os.path.exists(dir): + os.makedirs(dir) + except: + pass + self.downloaddir = dir self.url = url self.speed_label = actionrow.speed_label @@ -44,16 +89,22 @@ def __init__(self, app, url, actionrow, downloadname, download, mode, video_opti 'completed_length': 0, 'upload_length': 0, 'torrent_seeding_speed': "0 B/s", - 'torrent_peers': [] + 'torrent_peers': [], + 'resumable': _("Checking...") } + if self.mode == "video" or self.mode == "playlist": + self.download_details['resumable'] = _("Yes") + elif download and getattr(download, "is_torrent", False): + self.download_details['resumable'] = _("Yes") + if downloadname: self.downloadname = downloadname else: self.downloadname = "" try: - self.filepath = os.path.join(app.appconf["download_directory"], downloadname) + self.filepath = os.path.join(self.downloaddir, downloadname) except: self.filepath = None @@ -105,6 +156,37 @@ def run(self): GLib.idle_add(self.actionrow.info_button.set_visible, False) return + if self.download_details.get('resumable') == _("Checking..."): + is_torrent = self.url.startswith("magnet:") or self.url.lower().split('?')[0].endswith(".torrent") + if is_torrent: + self.download_details['resumable'] = _("Yes") + else: + def check_resumability(): + resumable = False + headers = {'Range': 'bytes=0-0'} + try: + req_headers = headers.copy() + if self.auth == '1' and self.auth_username and self.auth_password: + import base64 + auth_str = f"{self.auth_username}:{self.auth_password}" + auth_b64 = base64.b64encode(auth_str.encode('utf-8')).decode('utf-8') + req_headers['Authorization'] = f"Basic {auth_b64}" + + r = requests.get(self.url, headers=req_headers, allow_redirects=True, timeout=3, stream=True) + if r.status_code == 206: + resumable = True + else: + r_head = requests.head(self.url, headers=req_headers, allow_redirects=True, timeout=3) + if r_head.status_code == 200 and r_head.headers.get('Accept-Ranges') == 'bytes': + resumable = True + except: + pass + self.download_details['resumable'] = _("Yes") if resumable else _("No") + self.set_actionrow_tooltip_text() + + check_thread = threading.Thread(target=check_resumability, daemon=True) + check_thread.start() + download_options = {} if self.url.startswith("magnet:"): @@ -153,6 +235,8 @@ def run(self): # Regular download, use aria2p: if self.mode == "regular": + download_options["dir"] = self.downloaddir + if self.downloadname != None: download_options["out"] = self.downloadname @@ -179,6 +263,7 @@ def run(self): if self.download.is_torrent: self.download_details['type'] = _("Torrent") + self.download_details['resumable'] = _("Yes") self.change_download_type_icon("torrent") self.torrent_file_select_completed = False @@ -189,7 +274,7 @@ def run(self): self.previous_filename = "" self.app.filter_download_list("no", self.app.applied_filter) download_began = False - self.filepath = os.path.join(self.app.appconf["download_directory"], self.downloadname) + self.filepath = os.path.join(self.downloaddir, self.downloadname) if self.retry == False: self.save_state() @@ -213,7 +298,7 @@ def run(self): if (self.download.is_torrent and self.download.name.startswith("[METADATA]")) == False and self.downloadname != self.download.name: self.downloadname = self.download.name self.save_state() - self.filepath = os.path.join(self.app.appconf["download_directory"], self.downloadname) + self.filepath = os.path.join(self.downloaddir, self.downloadname) if self.actionrow.filename_label.get_text() != self.downloadname: GLib.idle_add(self.actionrow.filename_label.set_text, self.download.name) @@ -374,7 +459,11 @@ def set_actionrow_tooltip_text(self): + "\n" + _("File Name") + ": " + self.downloadname + "\n" + _("Type") + ": " + self.download_details['type'] + "\n" + _("Status") + ": " + self.download_details['status'] + + "\n" + _("Supports Resume") + ": " + self.download_details.get('resumable', '...') + "\n" + self.download_details['percentage']) + if hasattr(self.actionrow, "non_resumable_badge"): + is_no = (self.download_details.get('resumable') == _("No")) + GLib.idle_add(self.actionrow.non_resumable_badge.set_visible, is_no) def update_labels_and_things(self, video_object): speed_label_text = "" @@ -519,6 +608,8 @@ def update_labels_and_things(self, video_object): percentage_label_text = _("Part {indicator}").replace("{indicator}", "2 / 2") + " · " + percentage_label_text speed_label_text = f"{speed_label_text}{self.total_file_size_text} · {speed_label_text_speed} · {download_remaining_string} {_('remaining')}" + if self.download_details.get('resumable') == _("No"): + speed_label_text = f"{speed_label_text} · {_('Non-Resumable')}" self.download_details['message'] = "" self.set_actionrow_tooltip_text() @@ -822,6 +913,52 @@ def extract_archive(self): self.set_complete() def set_complete(self): + if self.app.appconf.get("automatic_sorting_enabled", "0") == "1": + is_torrent = getattr(self.download, "is_torrent", False) if self.download else False + if (self.mode == "regular" and not is_torrent) or self.mode == "video": + if self.video_download_is_playlist: + category = "Video" + else: + category = get_category_for_filename(self.downloadname) + + if category: + target_dir = os.path.join(self.app.appconf["download_directory"], category) + if self.video_download_is_playlist: + src_path = self.downloaddir + else: + src_path = os.path.join(self.downloaddir, self.downloadname) + dest_path = os.path.join(target_dir, self.downloadname) + + if os.path.exists(src_path) and os.path.abspath(src_path) != os.path.abspath(dest_path): + if not os.path.exists(target_dir): + try: + os.makedirs(target_dir) + except Exception as e: + print(f"Error creating category directory {target_dir}: {e}") + + import shutil + try: + base_dest, ext_dest = os.path.splitext(dest_path) + counter = 1 + while os.path.exists(dest_path): + if self.video_download_is_playlist: + dest_path = f"{base_dest}-{counter}" + else: + dest_path = f"{base_dest}-{counter}{ext_dest}" + counter += 1 + + shutil.move(src_path, dest_path) + if self.video_download_is_playlist: + self.downloaddir = dest_path + self.filepath = dest_path + else: + self.filepath = dest_path + self.downloaddir = target_dir + self.downloadname = os.path.basename(dest_path) + print(f"Moved completed download to: {dest_path}") + except Exception as e: + print(f"Error moving completed download: {e}") + self.is_complete = True self.app.filter_download_list("no", self.app.applied_filter) self.download_details['remaining'] = "" diff --git a/src/variamain.py b/src/variamain.py index b1e6476..b9464d4 100644 --- a/src/variamain.py +++ b/src/variamain.py @@ -621,7 +621,8 @@ def default_download_directory(): 'autostart_on_boot_enabled': 'false', 'extract_archives': '0', 'extract_archives_delete_archives': '0', - 'playlist_skip_errors': '0'} + 'playlist_skip_errors': '0', + 'automatic_sorting_enabled': '0'} if os.path.exists(os.path.join(appdir, 'varia.conf')): first_run = False diff --git a/src/window/preferences.py b/src/window/preferences.py index 6138ad0..ae5a400 100644 --- a/src/window/preferences.py +++ b/src/window/preferences.py @@ -112,6 +112,16 @@ def show_preferences(button, self, app, variaVersion): else: download_directory_actionrow.add_suffix(download_directory_change_remote_label) + # Automatic folder sorting: + + automatic_sorting = Adw.SwitchRow() + automatic_sorting.set_title(_("Automatic Folder Sorting")) + automatic_sorting.set_subtitle(_("Automatically sort downloads into folders based on file type (Compressed, Pictures, Documents, Video, Music).")) + automatic_sorting.connect("notify::active", on_automatic_sorting, self) + + if self.appconf["automatic_sorting_enabled"] == "1": + automatic_sorting.set_active("active") + # Extract archives: extract_archives_delete_archives = Adw.SwitchRow() @@ -271,6 +281,7 @@ def show_preferences(button, self, app, variaVersion): # Construct Group 1: group_1.add(download_directory_actionrow) + group_1.add(automatic_sorting) group_1.add(extract_archives) group_1.add(extract_archives_delete_archives) group_1.add(playlist_skip_errors) @@ -620,6 +631,15 @@ def on_playlist_skip_errors(switch, state, self): self.save_appconf() +def on_automatic_sorting(switch, state, self): + state = switch.get_active() + if state: + self.appconf["automatic_sorting_enabled"] = '1' + else: + self.appconf["automatic_sorting_enabled"] = '0' + + self.save_appconf() + def speed_limit_text_filter(entry, self): text = entry.get_text() new_text = ""