1# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*- 2### BEGIN LICENSE 3# Copyright (c) 2012-2019, Peter Levi <peterlevi@peterlevi.com> 4# Copyright (c) 2017-2019, James Lu <james@overdrivenetworks.com> 5# This program is free software: you can redistribute it and/or modify it 6# under the terms of the GNU General Public License version 3, as published 7# by the Free Software Foundation. 8# 9# This program is distributed in the hope that it will be useful, but 10# WITHOUT ANY WARRANTY; without even the implied warranties of 11# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR 12# PURPOSE. See the GNU General Public License for more details. 13# 14# You should have received a copy of the GNU General Public License along 15# with this program. If not, see <http://www.gnu.org/licenses/>. 16### END LICENSE 17import logging 18import os 19import random 20import re 21import shlex 22import shutil 23import stat 24import subprocess 25import sys 26import threading 27import time 28import urllib.parse 29import webbrowser 30 31from PIL import Image as PILImage 32 33from jumble.Jumble import Jumble 34from variety import indicator 35from variety.AboutVarietyDialog import AboutVarietyDialog 36from variety.DominantColors import DominantColors 37from variety.FlickrDownloader import FlickrDownloader 38from variety.ImageFetcher import ImageFetcher 39from variety.Options import Options 40from variety.plugins.downloaders.ConfigurableImageSource import ConfigurableImageSource 41from variety.plugins.downloaders.DefaultDownloader import SAFE_MODE_BLACKLIST 42from variety.plugins.downloaders.ImageSource import ImageSource 43from variety.plugins.downloaders.SimpleDownloader import SimpleDownloader 44from variety.plugins.IVarietyPlugin import IVarietyPlugin 45from variety.PreferencesVarietyDialog import PreferencesVarietyDialog 46from variety.PrivacyNoticeDialog import PrivacyNoticeDialog 47from variety.profile import ( 48 DEFAULT_PROFILE_PATH, 49 get_autostart_file_path, 50 get_desktop_file_name, 51 get_profile_path, 52 get_profile_short_name, 53 get_profile_wm_class, 54 is_default_profile, 55) 56from variety.QuotesEngine import QuotesEngine 57from variety.QuoteWriter import QuoteWriter 58from variety.ThumbsManager import ThumbsManager 59from variety.Util import Util, _, debounce, on_gtk, throttle 60from variety.VarietyOptionParser import parse_options 61from variety.WelcomeDialog import WelcomeDialog 62from variety_lib import varietyconfig 63 64# fmt: off 65import gi # isort:skip 66gi.require_version("Notify", "0.7") 67from gi.repository import Gdk, GdkPixbuf, Gio, GObject, Gtk, Notify # isort:skip 68Notify.init("Variety") 69# fmt: on 70 71 72random.seed() 73logger = logging.getLogger("variety") 74 75 76DL_FOLDER_FILE = ".variety_download_folder" 77 78DONATE_URL = ( 79 "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=DHQUELMQRQW46&lc=BG&item_name=" 80 "Variety%20Wallpaper%20Changer¤cy_code=EUR&bn=PP%2dDonationsBF%3abtn_donate_SM%2egif%3aNonHosted" 81) 82 83OUTDATED_MSG = "This version of Variety is outdated and unsupported. Please upgrade. Quitting." 84 85 86class VarietyWindow(Gtk.Window): 87 __gtype_name__ = "VarietyWindow" 88 89 SERVERSIDE_OPTIONS_URL = "http://tiny.cc/variety-options-063" 90 91 OUTDATED_SET_WP_SCRIPTS = { 92 "b8ff9cb65e3bb7375c4e2a6e9611c7f8", 93 "3729d3e1f57aa1159988ba2c8f929389", 94 "feafa658d9686ecfabdbcf236c32fd0f", 95 "83d8ebeec3676474bdd90c55417e8640", 96 "1562cb289319aa39ac1b37a8ee4c0103", 97 "6c54123e87e98b15d87f0341d3e36fc5", 98 "3f9fcc524bfee8fb146d1901613d3181", 99 "40db8163e22fbe8a505bfd1280190f0d", # 0.4.14, 0.4.15 100 "59a037428784caeb0834a8dd7897a88b", # 0.4.16, 0.4.17 101 "e4510e39fd6829ef550e128a1a4a036b", # 0.4.18 102 "d8d6a6c407a3d02ee242e9ce9ceaf293", # 0.4.19 103 "fdb69a2b16c62594c0fc12318ec58023", # 0.4.20 104 "236fa00c42af82904eaaecf2d460d21f", # 0.5.5 105 "6005ee48fc9cb48050af6e0e9572e660", # 0.6.6 (unary operator expected bug; LP: #1722433) 106 } 107 108 OUTDATED_GET_WP_SCRIPTS = { 109 "d8df22bf24baa87d5231e31027e79ee5", 110 "822aee143c6b3f1166e5d0a9c637dd16", # 0.4.16, 0.4.17 111 "367f629e2f24ad8040e46226b18fdc81", # 0.4.18, 0.4.19 112 } 113 114 # How many unseen_downloads max to for every downloader. 115 MAX_UNSEEN_PER_DOWNLOADER = 10 116 117 @classmethod 118 def get_instance(cls): 119 return VarietyWindow.instance 120 121 def __init__(self): 122 VarietyWindow.instance = self 123 124 def start(self, cmdoptions): 125 self.running = True 126 127 self.about = None 128 self.preferences_dialog = None 129 self.ind = None 130 131 try: 132 if Gio.SettingsSchemaSource.get_default().lookup("org.gnome.desktop.background", True): 133 self.gsettings = Gio.Settings.new("org.gnome.desktop.background") 134 else: 135 self.gsettings = None 136 except Exception: 137 self.gsettings = None 138 139 self.prepare_config_folder() 140 self.dialogs = [] 141 142 fr_file = os.path.join(self.config_folder, ".firstrun") 143 first_run = not os.path.exists(fr_file) 144 145 if first_run: # Make setup dialogs block so that privacy notice appears 146 self.show_welcome_dialog() 147 self.show_privacy_dialog() 148 149 self.thumbs_manager = ThumbsManager(self) 150 151 self.quotes_engine = None 152 self.quote = None 153 self.quote_favorites_contents = "" 154 self.clock_thread = None 155 156 self.perform_upgrade() 157 158 self.events = [] 159 160 self.prepared = [] 161 self.prepared_cleared = False 162 self.prepared_lock = threading.Lock() 163 164 self.register_clipboard() 165 166 self.do_set_wp_lock = threading.Lock() 167 self.auto_changed = True 168 169 self.process_command(cmdoptions, initial_run=True) 170 171 # load config 172 self.options = None 173 self.server_options = {} 174 self.load_banned() 175 self.load_history() 176 self.post_filter_filename = None 177 178 if self.position < len(self.used): 179 self.thumbs_manager.mark_active(file=self.used[self.position], position=self.position) 180 181 logger.info(lambda: "Using data_path %s" % varietyconfig.get_data_path()) 182 self.jumble = Jumble( 183 [os.path.join(os.path.dirname(__file__), "plugins", "builtin"), self.plugins_folder] 184 ) 185 186 setattr(self.jumble, "parent", self) 187 self.jumble.load() 188 189 self.image_count = -1 190 self.image_colors_cache = {} 191 192 self.load_downloader_plugins() 193 self.create_downloaders_cache() 194 self.reload_config() 195 self.load_last_change_time() 196 197 self.update_indicator(auto_changed=False) 198 199 self.start_threads() 200 201 if first_run: 202 self.first_run(fr_file) 203 204 def _delayed(): 205 self.create_preferences_dialog() 206 207 for plugin in self.jumble.get_plugins(clazz=IVarietyPlugin): 208 threading.Timer(0, plugin["plugin"].on_variety_start_complete).start() 209 210 GObject.timeout_add(1000, _delayed) 211 212 def on_mnu_about_activate(self, widget, data=None): 213 """Display the about box for variety.""" 214 if self.about is not None: 215 logger.debug(lambda: "show existing about_dialog") 216 self.about.set_keep_above(True) 217 self.about.present() 218 self.about.set_keep_above(False) 219 self.about.present() 220 else: 221 logger.debug(lambda: "create new about dialog") 222 self.about = AboutVarietyDialog() # pylint: disable=E1102 223 # Set the version on runtime. 224 Gtk.AboutDialog.set_version(self.about, varietyconfig.get_version()) 225 self.about.run() 226 self.about.destroy() 227 self.about = None 228 229 def on_mnu_donate_activate(self, widget, data=None): 230 self.preferences_dialog.ui.notebook.set_current_page(8) 231 self.on_mnu_preferences_activate() 232 webbrowser.open_new_tab(DONATE_URL) 233 234 def get_preferences_dialog(self): 235 if not self.preferences_dialog: 236 self.create_preferences_dialog() 237 return self.preferences_dialog 238 239 def create_preferences_dialog(self): 240 if not self.preferences_dialog: 241 logger.debug(lambda: "create new preferences_dialog") 242 self.preferences_dialog = PreferencesVarietyDialog(parent=self) # pylint: disable=E1102 243 244 def _on_preferences_dialog_destroyed(widget, data=None): 245 logger.debug(lambda: "on_preferences_dialog_destroyed") 246 self.preferences_dialog = None 247 248 self.preferences_dialog.connect("destroy", _on_preferences_dialog_destroyed) 249 250 def _on_preferences_close_button(arg1, arg2): 251 self.preferences_dialog.close() 252 return True 253 254 self.preferences_dialog.connect("delete_event", _on_preferences_close_button) 255 256 def on_mnu_preferences_activate(self, widget=None, data=None): 257 """Display the preferences window for variety.""" 258 if self.preferences_dialog is not None: 259 if self.preferences_dialog.get_visible(): 260 logger.debug(lambda: "bring to front existing and visible preferences_dialog") 261 self.preferences_dialog.set_keep_above(True) 262 self.preferences_dialog.present() 263 self.preferences_dialog.set_keep_above(False) 264 else: 265 logger.debug(lambda: "reload and show existing but non-visible preferences_dialog") 266 self.preferences_dialog.reload() 267 self.preferences_dialog.show() 268 else: 269 self.create_preferences_dialog() 270 self.preferences_dialog.show() 271 # destroy command moved into dialog to allow for a help button 272 273 self.preferences_dialog.present() 274 275 def prepare_config_folder(self): 276 self.config_folder = get_profile_path() 277 Util.makedirs(self.config_folder) 278 279 Util.copy_with_replace( 280 varietyconfig.get_data_file("config", "variety.conf"), 281 os.path.join(self.config_folder, "variety_latest_default.conf"), 282 {DEFAULT_PROFILE_PATH: get_profile_path(expanded=False)}, 283 ) 284 285 if not os.path.exists(os.path.join(self.config_folder, "variety.conf")): 286 logger.info( 287 lambda: "Missing config file, copying it from " 288 + varietyconfig.get_data_file("config", "variety.conf") 289 ) 290 Util.copy_with_replace( 291 varietyconfig.get_data_file("config", "variety.conf"), 292 os.path.join(self.config_folder, "variety.conf"), 293 {DEFAULT_PROFILE_PATH: get_profile_path(expanded=False)}, 294 ) 295 296 if not os.path.exists(os.path.join(self.config_folder, "ui.conf")): 297 logger.info( 298 lambda: "Missing ui.conf file, copying it from " 299 + varietyconfig.get_data_file("config", "ui.conf") 300 ) 301 shutil.copy(varietyconfig.get_data_file("config", "ui.conf"), self.config_folder) 302 303 self.plugins_folder = os.path.join(self.config_folder, "plugins") 304 Util.makedirs(self.plugins_folder) 305 306 self.scripts_folder = os.path.join(self.config_folder, "scripts") 307 Util.makedirs(self.scripts_folder) 308 309 if not os.path.exists(os.path.join(self.scripts_folder, "set_wallpaper")): 310 logger.info( 311 lambda: "Missing set_wallpaper file, copying it from " 312 + varietyconfig.get_data_file("scripts", "set_wallpaper") 313 ) 314 Util.copy_with_replace( 315 varietyconfig.get_data_file("scripts", "set_wallpaper"), 316 os.path.join(self.scripts_folder, "set_wallpaper"), 317 {DEFAULT_PROFILE_PATH.replace("~", "$HOME"): get_profile_path(expanded=True)}, 318 ) 319 320 if not os.path.exists(os.path.join(self.scripts_folder, "get_wallpaper")): 321 logger.info( 322 lambda: "Missing get_wallpaper file, copying it from " 323 + varietyconfig.get_data_file("scripts", "get_wallpaper") 324 ) 325 Util.copy_with_replace( 326 varietyconfig.get_data_file("scripts", "get_wallpaper"), 327 os.path.join(self.scripts_folder, "get_wallpaper"), 328 {DEFAULT_PROFILE_PATH.replace("~", "$HOME"): get_profile_path(expanded=True)}, 329 ) 330 331 # make all scripts executable: 332 for f in os.listdir(self.scripts_folder): 333 path = os.path.join(self.scripts_folder, f) 334 os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC) 335 336 self.wallpaper_folder = os.path.join(self.config_folder, "wallpaper") 337 Util.makedirs(self.wallpaper_folder) 338 339 self.create_desktop_entry() 340 341 def register_clipboard(self): 342 def clipboard_changed(clipboard, event): 343 try: 344 if not self.options.clipboard_enabled: 345 return 346 347 text = clipboard.wait_for_text() 348 logger.debug(lambda: "Clipboard: %s" % text) 349 if not text: 350 return 351 352 valid = [ 353 url 354 for url in text.split("\n") 355 if ImageFetcher.url_ok( 356 url, self.options.clipboard_use_whitelist, self.options.clipboard_hosts 357 ) 358 ] 359 360 if valid: 361 logger.info(lambda: "Received clipboard URLs: " + str(valid)) 362 self.process_urls(valid, verbose=False) 363 except Exception: 364 logger.exception(lambda: "Exception when processing clipboard:") 365 366 self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 367 self.clipboard.connect("owner-change", clipboard_changed) 368 369 def log_options(self): 370 logger.info(lambda: "Loaded options:") 371 for k, v in sorted(self.options.__dict__.items()): 372 logger.info(lambda: "%s = %s" % (k, v)) 373 374 def get_real_download_folder(self): 375 subfolder = "Downloaded by Variety" 376 dl = self.options.download_folder 377 378 # If chosen folder is within Variety's config folder, or folder's name is "Downloaded by Variety", 379 # or folder is missing or it is empty or it has already been used as a download folder, then use it: 380 if ( 381 Util.file_in(dl, self.config_folder) 382 or dl.endswith("/%s" % subfolder) 383 or dl.endswith("/%s/" % subfolder) 384 or not os.path.exists(dl) 385 or not os.listdir(dl) 386 or os.path.exists(os.path.join(dl, DL_FOLDER_FILE)) 387 ): 388 return dl 389 else: 390 # In all other cases (i.e. it is an existing user folder with files in it), use a subfolder inside it 391 return os.path.join(dl, subfolder) 392 393 def prepare_download_folder(self): 394 self.real_download_folder = self.get_real_download_folder() 395 Util.makedirs(self.real_download_folder) 396 dl_folder_file = os.path.join(self.real_download_folder, DL_FOLDER_FILE) 397 if not os.path.exists(dl_folder_file): 398 with open(dl_folder_file, "w") as f: 399 f.write(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) 400 401 def load_downloader_plugins(self): 402 Options.IMAGE_SOURCES = [p["plugin"] for p in self.jumble.get_plugins(ImageSource)] 403 Options.CONFIGURABLE_IMAGE_SOURCES = [ 404 p for p in Options.IMAGE_SOURCES if isinstance(p, ConfigurableImageSource) 405 ] 406 Options.CONFIGURABLE_IMAGE_SOURCES_MAP = { 407 s.get_source_type(): s for s in Options.CONFIGURABLE_IMAGE_SOURCES 408 } 409 Options.SIMPLE_DOWNLOADERS = [ 410 p for p in Options.IMAGE_SOURCES if isinstance(p, SimpleDownloader) 411 ] 412 for image_source in Options.IMAGE_SOURCES: 413 image_source.activate() 414 image_source.set_variety(self) 415 416 def reload_config(self): 417 self.previous_options = self.options 418 419 self.options = Options() 420 self.options.read() 421 422 self.update_indicator_icon() 423 424 self.prepare_download_folder() 425 426 Util.makedirs(self.options.favorites_folder) 427 Util.makedirs(self.options.fetched_folder) 428 429 self.individual_images = [ 430 os.path.expanduser(s[2]) 431 for s in self.options.sources 432 if s[0] and s[1] == Options.SourceType.IMAGE 433 ] 434 435 self.folders = [ 436 os.path.expanduser(s[2]) 437 for s in self.options.sources 438 if s[0] and s[1] == Options.SourceType.FOLDER 439 ] 440 441 if Options.SourceType.FAVORITES in [s[1] for s in self.options.sources if s[0]]: 442 self.folders.append(self.options.favorites_folder) 443 444 if Options.SourceType.FETCHED in [s[1] for s in self.options.sources if s[0]]: 445 self.folders.append(self.options.fetched_folder) 446 447 self.downloaders = [] 448 self.download_folder_size = None 449 450 self.albums = [] 451 452 if self.size_options_changed(): 453 logger.info(lambda: "Size/landscape settings changed - purging downloaders cache") 454 self.create_downloaders_cache() 455 456 for s in self.options.sources: 457 enabled, type, location = s 458 459 if not enabled: 460 continue 461 462 # prepare a cache for albums to avoid walking those folders on every change 463 if type in (Options.SourceType.ALBUM_FILENAME, Options.SourceType.ALBUM_DATE): 464 images = Util.list_files(folders=(location,), filter_func=Util.is_image) 465 if type == Options.SourceType.ALBUM_FILENAME: 466 images = sorted(images) 467 elif type == Options.SourceType.ALBUM_DATE: 468 images = sorted(images, key=os.path.getmtime) 469 else: 470 raise Exception("Unsupported album type") 471 472 if images: 473 self.albums.append({"path": os.path.normpath(location), "images": images}) 474 475 continue 476 477 if type not in self.options.get_downloader_source_types(): 478 continue 479 480 if location in self.downloaders_cache[type]: 481 self.downloaders.append(self.downloaders_cache[type][location]) 482 else: 483 try: 484 logger.info( 485 lambda: "Creating new downloader for type %s, location %s" 486 % (type, location) 487 ) 488 dlr = self.create_downloader(type, location) 489 self.downloaders_cache[type][location] = dlr 490 self.downloaders.append(dlr) 491 except Exception: 492 logger.exception( 493 lambda: "Could not create Downloader for type %s, location %s" 494 % (type, location) 495 ) 496 497 for downloader in Options.SIMPLE_DOWNLOADERS: 498 downloader.update_download_folder(self.real_download_folder) 499 500 for downloader in self.downloaders: 501 downloader.update_download_folder(self.real_download_folder) 502 Util.makedirs(downloader.target_folder) 503 self.folders.append(downloader.target_folder) 504 505 self.filters = [f[2] for f in self.options.filters if f[0]] 506 507 self.min_width = 0 508 self.min_height = 0 509 if self.options.min_size_enabled: 510 self.min_width = Gdk.Screen.get_default().get_width() * self.options.min_size // 100 511 self.min_height = Gdk.Screen.get_default().get_height() * self.options.min_size // 100 512 513 self.log_options() 514 515 # clean prepared - they are outdated 516 if self.should_clear_prepared(): 517 self.clear_prepared_queue() 518 else: 519 logger.info(lambda: "No need to clear prepared queue") 520 521 self.start_clock_thread() 522 523 if self.options.quotes_enabled: 524 if not self.quotes_engine: 525 self.quotes_engine = QuotesEngine(self) 526 self.quotes_engine.start() 527 else: 528 if self.quotes_engine: 529 self.quotes_engine.stop() 530 531 if self.quotes_engine: 532 self.reload_quote_favorites_contents() 533 clear_prepared = ( 534 self.previous_options is None 535 or self.options.quotes_disabled_sources 536 != self.previous_options.quotes_disabled_sources 537 or self.options.quotes_tags != self.previous_options.quotes_tags 538 or self.options.quotes_authors != self.previous_options.quotes_authors 539 ) 540 self.quotes_engine.on_options_updated(clear_prepared=clear_prepared) 541 542 if self.previous_options and ( 543 self.options.filters != self.previous_options.filters 544 or self.options.quotes_enabled != self.previous_options.quotes_enabled 545 or self.options.clock_enabled != self.previous_options.clock_enabled 546 ): 547 self.no_effects_on = None 548 549 self.update_indicator(auto_changed=False) 550 551 if self.previous_options is None or self.options.filters != self.previous_options.filters: 552 threading.Timer(0.1, self.refresh_wallpaper).start() 553 else: 554 threading.Timer(0.1, self.refresh_texts).start() 555 556 if self.events: 557 for e in self.events: 558 e.set() 559 560 def clear_prepared_queue(self): 561 self.filters_warning_shown = False 562 logger.info(lambda: "Clearing prepared queue") 563 with self.prepared_lock: 564 self.prepared_cleared = True 565 self.prepared = [] 566 self.prepare_event.set() 567 self.image_count = -1 568 569 def should_clear_prepared(self): 570 return self.previous_options and ( 571 [s for s in self.previous_options.sources if s[0]] 572 != [s for s in self.options.sources if s[0]] 573 or self.filtering_options_changed() 574 ) 575 576 def filtering_options_changed(self): 577 if not self.previous_options: 578 return False 579 if self.size_options_changed(): 580 return True 581 if self.previous_options.safe_mode != self.options.safe_mode: 582 return True 583 if ( 584 self.previous_options.desired_color_enabled != self.options.desired_color_enabled 585 or self.previous_options.desired_color != self.options.desired_color 586 ): 587 return True 588 if ( 589 self.previous_options.lightness_enabled != self.options.lightness_enabled 590 or self.previous_options.lightness_mode != self.options.lightness_mode 591 ): 592 return True 593 if ( 594 self.previous_options.min_rating_enabled != self.options.min_rating_enabled 595 or self.previous_options.min_rating != self.options.min_rating 596 ): 597 return True 598 return False 599 600 def size_options_changed(self): 601 return self.previous_options and ( 602 self.previous_options.min_size_enabled != self.options.min_size_enabled 603 or self.previous_options.min_size != self.options.min_size 604 or self.previous_options.use_landscape_enabled != self.options.use_landscape_enabled 605 ) 606 607 def create_downloaders_cache(self): 608 self.downloaders_cache = {} 609 for type in Options.get_downloader_source_types(): 610 self.downloaders_cache[type] = {} 611 612 def create_downloader(self, type, location): 613 if type == Options.SourceType.FLICKR: 614 return FlickrDownloader(self, location) 615 else: 616 for dl in Options.SIMPLE_DOWNLOADERS: 617 if dl.get_source_type() == type: 618 return dl 619 for source in Options.CONFIGURABLE_IMAGE_SOURCES: 620 if source.get_source_type() == type: 621 return source.create_downloader(location) 622 623 raise Exception("Unknown downloader type") 624 625 def get_folder_of_source(self, source): 626 type = source[1] 627 location = source[2] 628 629 if type == Options.SourceType.IMAGE: 630 return None 631 elif type in Options.SourceType.LOCAL_PATH_TYPES: 632 return location 633 elif type == Options.SourceType.FAVORITES: 634 return self.options.favorites_folder 635 elif type == Options.SourceType.FETCHED: 636 return self.options.fetched_folder 637 else: 638 dlr = self.create_downloader(type, location) 639 dlr.update_download_folder(self.real_download_folder) 640 return dlr.target_folder 641 642 def delete_files_of_source(self, source): 643 folder = self.get_folder_of_source(source) 644 if Util.file_in(folder, self.real_download_folder): 645 self.remove_folder_from_queues(folder) 646 should_repaint = ( 647 self.thumbs_manager.is_showing("history") 648 or self.thumbs_manager.is_showing("downloads") 649 or ( 650 self.thumbs_manager.get_folders() is not None 651 and folder in self.thumbs_manager.get_folders() 652 ) 653 ) 654 655 if should_repaint: 656 self.thumbs_manager.repaint() 657 try: 658 logger.info(lambda: "Deleting recursively folder " + folder) 659 shutil.rmtree(folder) 660 except Exception: 661 logger.exception(lambda: "Could not delete download folder contents " + folder) 662 if self.current and Util.file_in(self.current, folder): 663 change_timer = threading.Timer(0, self.next_wallpaper) 664 change_timer.start() 665 666 def load_banned(self): 667 self.banned = set() 668 try: 669 banned = os.path.join(self.config_folder, "banned.txt") 670 with open(banned, encoding="utf8") as f: 671 for line in f: 672 self.banned.add(line.strip()) 673 except Exception: 674 logger.info(lambda: "Missing or invalid banned URLs list, no URLs will be banned") 675 676 def start_clock_thread(self): 677 if not self.clock_thread and self.options.clock_enabled: 678 self.clock_event = threading.Event() 679 self.events.append(self.clock_event) 680 self.clock_thread = threading.Thread(target=self.clock_thread_method) 681 self.clock_thread.daemon = True 682 self.clock_thread.start() 683 684 def start_threads(self): 685 self.change_event = threading.Event() 686 change_thread = threading.Thread(target=self.regular_change_thread) 687 change_thread.daemon = True 688 change_thread.start() 689 690 self.prepare_event = threading.Event() 691 prep_thread = threading.Thread(target=self.prepare_thread) 692 prep_thread.daemon = True 693 prep_thread.start() 694 695 self.dl_event = threading.Event() 696 dl_thread = threading.Thread(target=self.download_thread) 697 dl_thread.daemon = True 698 dl_thread.start() 699 700 self.events.extend([self.change_event, self.prepare_event, self.dl_event]) 701 702 server_options_thread = threading.Thread(target=self.server_options_thread) 703 server_options_thread.daemon = True 704 server_options_thread.start() 705 706 def is_in_favorites(self, file): 707 filename = os.path.basename(file) 708 return os.path.exists(os.path.join(self.options.favorites_folder, filename)) 709 710 def is_current_refreshable(self): 711 return "--refreshable" in self.current 712 713 def update_favorites_menuitems(self, holder, auto_changed, favs_op): 714 if auto_changed: 715 # delay enabling Move/Copy operations in this case - see comment below 716 holder.copy_to_favorites.set_sensitive(False) 717 holder.move_to_favorites.set_sensitive(False) 718 else: 719 holder.copy_to_favorites.set_sensitive(favs_op in ("copy", "both")) 720 holder.move_to_favorites.set_sensitive(favs_op in ("move", "both")) 721 if favs_op is None: 722 holder.copy_to_favorites.set_visible(False) 723 holder.move_to_favorites.set_visible(False) 724 elif favs_op == "favorite": 725 holder.copy_to_favorites.set_label(_("Already in Favorites")) 726 holder.copy_to_favorites.set_visible(True) 727 holder.move_to_favorites.set_visible(False) 728 else: 729 holder.copy_to_favorites.set_label(_("Copy to _Favorites")) 730 holder.move_to_favorites.set_label(_("Move to _Favorites")) 731 if favs_op == "copy": 732 holder.copy_to_favorites.set_visible(True) 733 holder.move_to_favorites.set_visible(False) 734 elif favs_op == "move": 735 holder.copy_to_favorites.set_visible(False) 736 holder.move_to_favorites.set_visible(True) 737 else: # both 738 holder.move_to_favorites.set_label(_("Move to Favorites")) 739 holder.copy_to_favorites.set_visible(True) 740 holder.move_to_favorites.set_visible(True) 741 742 def update_indicator(self, file=None, auto_changed=None): 743 if not file: 744 file = self.current 745 if auto_changed is None: 746 auto_changed = self.auto_changed 747 748 logger.info(lambda: "Setting file info to: %s" % file) 749 try: 750 self.url = None 751 self.image_url = None 752 self.source_name = None 753 754 label = os.path.dirname(file).replace("_", "__") if file else None 755 info = Util.read_metadata(file) if file else None 756 if info and "sourceURL" in info and "sourceName" in info: 757 self.source_name = info["sourceName"] 758 if "Fetched" in self.source_name: 759 self.source_name = None 760 label = _("Fetched: Show Origin") 761 elif "noOriginPage" in info: 762 label = _("Source: %s") % self.source_name 763 else: 764 label = _("View at %s") % self.source_name 765 766 self.url = info["sourceURL"] 767 if self.url.startswith("//"): 768 self.url = "https:" + self.url 769 770 if "imageURL" in info: 771 self.image_url = info["imageURL"] 772 if self.image_url.startswith("//"): 773 self.image_url = self.url.split("//")[0] + self.image_url 774 775 if label and len(label) > 50: 776 label = label[:50] + "..." 777 778 author = None 779 if info and "author" in info and "authorURL" in info: 780 author = info["author"] 781 if len(author) > 50: 782 author = author[:50] + "..." 783 self.author_url = info["authorURL"] 784 else: 785 self.author_url = None 786 787 if not self.ind: 788 return 789 790 deleteable = ( 791 bool(file) and os.access(file, os.W_OK) and not self.is_current_refreshable() 792 ) 793 favs_op = self.determine_favorites_operation(file) 794 image_source = self.get_source(file) 795 796 downloaded = list( 797 Util.list_files( 798 files=[], 799 folders=[self.real_download_folder], 800 filter_func=Util.is_image, 801 max_files=1, 802 randomize=False, 803 ) 804 ) 805 806 def _gtk_update(): 807 rating_menu = None 808 if deleteable: 809 rating_menu = ThumbsManager.create_rating_menu(file, self) 810 811 quote_not_fav = True 812 if self.options.quotes_enabled and self.quote is not None: 813 quote_not_fav = ( 814 self.quote is not None 815 and self.quote_favorites_contents.find(self.current_quote_to_text()) == -1 816 ) 817 818 for i in range(3): 819 # if only done once, the menu is not always updated for some reason 820 self.ind.prev.set_sensitive(self.position < len(self.used) - 1) 821 if getattr(self.ind, "prev_main", None): 822 self.ind.prev_main.set_sensitive(self.position < len(self.used) - 1) 823 self.ind.fast_forward.set_sensitive(self.position > 0) 824 825 self.ind.file_label.set_visible(bool(file)) 826 self.ind.file_label.set_sensitive(bool(file)) 827 self.ind.file_label.set_label( 828 os.path.basename(file).replace("_", "__") if file else _("Unknown") 829 ) 830 831 self.ind.focus.set_sensitive(image_source is not None) 832 833 # delay enabling Trash if auto_changed 834 self.ind.trash.set_visible(bool(file)) 835 self.ind.trash.set_sensitive(deleteable and not auto_changed) 836 837 self.update_favorites_menuitems(self.ind, auto_changed, favs_op) 838 839 self.ind.show_origin.set_visible(bool(label)) 840 self.ind.show_origin.set_sensitive(bool(info and "noOriginPage" not in info)) 841 if label: 842 self.ind.show_origin.set_label(label) 843 844 if not author: 845 self.ind.show_author.set_visible(False) 846 self.ind.show_author.set_sensitive(False) 847 else: 848 self.ind.show_author.set_visible(True) 849 self.ind.show_author.set_sensitive(True) 850 self.ind.show_author.set_label(_("Author: %s") % author) 851 852 self.ind.rating.set_sensitive(rating_menu is not None) 853 if rating_menu: 854 self.ind.rating.set_submenu(rating_menu) 855 856 self.ind.history.handler_block(self.ind.history_handler_id) 857 self.ind.history.set_active(self.thumbs_manager.is_showing("history")) 858 self.ind.history.handler_unblock(self.ind.history_handler_id) 859 860 self.ind.downloads.set_visible(len(self.downloaders) > 0) 861 self.ind.downloads.set_sensitive(len(downloaded) > 0) 862 self.ind.downloads.handler_block(self.ind.downloads_handler_id) 863 self.ind.downloads.set_active(self.thumbs_manager.is_showing("downloads")) 864 self.ind.downloads.handler_unblock(self.ind.downloads_handler_id) 865 866 self.ind.selector.handler_block(self.ind.selector_handler_id) 867 self.ind.selector.set_active(self.thumbs_manager.is_showing("selector")) 868 self.ind.selector.handler_unblock(self.ind.selector_handler_id) 869 870 self.ind.google_image.set_sensitive(self.image_url is not None) 871 872 self.ind.pause_resume.set_label( 873 _("Pause on current") 874 if self.options.change_enabled 875 else _("Resume regular changes") 876 ) 877 878 if self.options.quotes_enabled and self.quote is not None: 879 self.ind.quotes.set_visible(True) 880 self.ind.google_quote_author.set_visible( 881 self.quote.get("author", None) is not None 882 ) 883 if "sourceName" in self.quote and "link" in self.quote: 884 self.ind.view_quote.set_visible(True) 885 self.ind.view_quote.set_label( 886 _("View at %s") % self.quote["sourceName"] 887 ) 888 else: 889 self.ind.view_quote.set_visible(False) 890 891 if self.quotes_engine: 892 self.ind.prev_quote.set_sensitive(self.quotes_engine.has_previous()) 893 894 self.ind.quotes_pause_resume.set_label( 895 _("Pause on current") 896 if self.options.quotes_change_enabled 897 else _("Resume regular changes") 898 ) 899 900 self.ind.quote_favorite.set_sensitive(quote_not_fav) 901 self.ind.quote_favorite.set_label( 902 _("Save to Favorites") if quote_not_fav else _("Already in Favorites") 903 ) 904 self.ind.quote_view_favs.set_sensitive( 905 os.path.isfile(self.options.quotes_favorites_file) 906 ) 907 908 self.ind.quote_clipboard.set_sensitive(self.quote is not None) 909 910 else: 911 self.ind.quotes.set_visible(False) 912 913 no_effects_visible = ( 914 self.filters or self.options.quotes_enabled or self.options.clock_enabled 915 ) 916 self.ind.no_effects.set_visible(no_effects_visible) 917 self.ind.no_effects.handler_block(self.ind.no_effects_handler_id) 918 self.ind.no_effects.set_active(self.no_effects_on == file) 919 self.ind.no_effects.handler_unblock(self.ind.no_effects_handler_id) 920 921 Util.add_mainloop_task(_gtk_update) 922 923 # delay enabling Move/Copy operations after automatic changes - protect from inadvertent clicks 924 if auto_changed: 925 926 def update_file_operations(): 927 for i in range(5): 928 self.ind.trash.set_sensitive(deleteable) 929 self.ind.copy_to_favorites.set_sensitive(favs_op in ("copy", "both")) 930 self.ind.move_to_favorites.set_sensitive(favs_op in ("move", "both")) 931 932 GObject.timeout_add(2000, update_file_operations) 933 934 except Exception: 935 logger.exception(lambda: "Error updating file info") 936 937 def regular_change_thread(self): 938 logger.info(lambda: "regular_change thread running") 939 940 if self.options.change_on_start: 941 self.change_event.wait(5) # wait for prepare thread to prepare some images first 942 self.auto_changed = True 943 self.change_wallpaper() 944 945 while self.running: 946 try: 947 while ( 948 not self.options.change_enabled 949 or (time.time() - self.last_change_time) < self.options.change_interval 950 ): 951 if not self.running: 952 return 953 now = time.time() 954 wait_more = self.options.change_interval - max(0, (now - self.last_change_time)) 955 if self.options.change_enabled: 956 self.change_event.wait(max(0, wait_more)) 957 else: 958 logger.info(lambda: "regular_change: waiting till user resumes") 959 self.change_event.wait() 960 self.change_event.clear() 961 if not self.running: 962 return 963 if not self.options.change_enabled: 964 continue 965 logger.info(lambda: "regular_change changes wallpaper") 966 self.auto_changed = True 967 self.last_change_time = time.time() 968 self.change_wallpaper() 969 except Exception: 970 logger.exception(lambda: "Exception in regular_change_thread") 971 972 def clock_thread_method(self): 973 logger.info(lambda: "clock thread running") 974 975 last_minute = -1 976 while self.running: 977 try: 978 while not self.options.clock_enabled: 979 self.clock_event.wait() 980 self.clock_event.clear() 981 982 if not self.running: 983 return 984 if not self.options.clock_enabled: 985 continue 986 987 time.sleep(1) 988 minute = int(time.strftime("%M", time.localtime())) 989 if minute != last_minute: 990 logger.info(lambda: "clock_thread updates wallpaper") 991 self.auto_changed = False 992 self.refresh_clock() 993 last_minute = minute 994 except Exception: 995 logger.exception(lambda: "Exception in clock_thread") 996 997 def find_images(self): 998 self.prepared_cleared = False 999 images = self.select_random_images(100 if not self.options.safe_mode else 30) 1000 1001 found = set() 1002 for fuzziness in range(0, 5): 1003 if len(found) > 10 or len(found) >= len(images): 1004 break 1005 for img in images: 1006 if not self.running or self.prepared_cleared: 1007 # abandon this search 1008 return 1009 1010 try: 1011 if not img in found and self.image_ok(img, fuzziness): 1012 # print "OK at fz %d: %s" % (fuzziness, img) 1013 found.add(img) 1014 if len(self.prepared) < 3 and not self.prepared_cleared: 1015 with self.prepared_lock: 1016 self.prepared.append(img) 1017 except Exception: 1018 logger.exception(lambda: "Excepion while testing image_ok on file " + img) 1019 1020 with self.prepared_lock: 1021 if self.prepared_cleared: 1022 # abandon this search 1023 return 1024 1025 self.prepared.extend(found) 1026 if not self.prepared and images: 1027 logger.info( 1028 lambda: "Prepared buffer still empty after search, appending some non-ok image" 1029 ) 1030 self.prepared.append(images[random.randint(0, len(images) - 1)]) 1031 1032 # remove duplicates 1033 self.prepared = list(set(self.prepared)) 1034 random.shuffle(self.prepared) 1035 1036 if len(images) < 3 and self.has_real_downloaders(): 1037 self.trigger_download() 1038 1039 if ( 1040 len(found) <= 5 1041 and len(images) >= max(20, 10 * len(found)) 1042 and found.issubset(set(self.used[:10])) 1043 ): 1044 logger.warning(lambda: "Too few images found: %d out of %d" % (len(found), len(images))) 1045 if not hasattr(self, "filters_warning_shown") or not self.filters_warning_shown: 1046 self.filters_warning_shown = True 1047 self.show_notification( 1048 _("Filtering too strict?"), 1049 _("Variety is finding too few images that match your image filtering criteria"), 1050 ) 1051 1052 def prepare_thread(self): 1053 logger.info(lambda: "Prepare thread running") 1054 while self.running: 1055 try: 1056 logger.info(lambda: "Prepared buffer contains %s images" % len(self.prepared)) 1057 if self.image_count < 0 or len(self.prepared) <= min(10, self.image_count // 2): 1058 logger.info(lambda: "Preparing some images") 1059 self.find_images() 1060 if not self.running: 1061 return 1062 logger.info( 1063 lambda: "After search prepared buffer contains %s images" 1064 % len(self.prepared) 1065 ) 1066 1067 # trigger download after some interval to reduce resource usage while the wallpaper changes 1068 delay_dl_timer = threading.Timer(2, self.trigger_download) 1069 delay_dl_timer.daemon = True 1070 delay_dl_timer.start() 1071 except Exception: 1072 logger.exception(lambda: "Error in prepare thread:") 1073 1074 self.prepare_event.wait() 1075 self.prepare_event.clear() 1076 1077 def server_options_thread(self): 1078 time.sleep(20) 1079 attempts = 0 1080 while self.running: 1081 try: 1082 attempts += 1 1083 logger.info( 1084 lambda: "Fetching server options from %s" % VarietyWindow.SERVERSIDE_OPTIONS_URL 1085 ) 1086 self.server_options = Util.fetch_json(VarietyWindow.SERVERSIDE_OPTIONS_URL) 1087 logger.info(lambda: "Fetched server options: %s" % str(self.server_options)) 1088 if self.preferences_dialog: 1089 self.preferences_dialog.update_status_message() 1090 1091 if varietyconfig.get_version() in self.server_options.get("outdated_versions", []): 1092 self.show_notification("Version unsupported", OUTDATED_MSG) 1093 self.on_quit() 1094 except Exception: 1095 logger.exception(lambda: "Could not fetch Variety serverside options") 1096 if attempts < 5: 1097 # the first several attempts may easily fail if Variety is run on startup, try again soon: 1098 time.sleep(30) 1099 continue 1100 1101 time.sleep(3600 * 24) # Update once daily 1102 1103 def has_real_downloaders(self): 1104 return sum(1 for d in self.downloaders if not d.is_refresher()) > 0 1105 1106 def download_thread(self): 1107 while self.running: 1108 try: 1109 available_downloaders = self._available_downloaders() 1110 1111 if not available_downloaders: 1112 self.dl_event.wait(180) 1113 self.dl_event.clear() 1114 continue 1115 1116 # download from the downloader with the smallest unseen queue 1117 downloader = sorted( 1118 available_downloaders, key=lambda dl: len(dl.state.get("unseen_downloads", [])) 1119 )[0] 1120 self.download_one_from(downloader) 1121 1122 # Also refresh the images for all refreshers that haven't downloaded recently - 1123 # these need to be updated regularly 1124 for dl in available_downloaders: 1125 if dl.is_refresher() and dl != downloader: 1126 dl.download_one() 1127 1128 # give some breathing room between downloads 1129 time.sleep(1) 1130 except Exception: 1131 logger.exception(lambda: "Exception in download_thread:") 1132 1133 def _available_downloaders(self): 1134 now = time.time() 1135 return [ 1136 dl 1137 for dl in self.downloaders 1138 if dl.state.get("last_download_failure", 0) < now - 60 1139 and (not dl.is_refresher() or dl.state.get("last_download_success", 0) < now - 60) 1140 and len(dl.state.get("unseen_downloads", [])) <= VarietyWindow.MAX_UNSEEN_PER_DOWNLOADER 1141 ] 1142 1143 def trigger_download(self): 1144 logger.info(lambda: "Triggering download thread to check if download needed") 1145 if getattr(self, "dl_event"): 1146 self.dl_event.set() 1147 1148 def register_downloaded_file(self, file): 1149 self.refresh_thumbs_downloads(file) 1150 1151 if file.startswith(self.options.download_folder) and self.download_folder_size is not None: 1152 self.download_folder_size += os.path.getsize(file) 1153 1154 # every once in a while, check the Downloaded folder against the allowed quota 1155 if random.random() < 0.05: 1156 self.purge_downloaded() 1157 1158 def download_one_from(self, downloader): 1159 try: 1160 file = downloader.download_one() 1161 except: 1162 logger.exception(lambda: "Could not download wallpaper:") 1163 file = None 1164 1165 if file: 1166 self.register_downloaded_file(file) 1167 downloader.state["last_download_success"] = time.time() 1168 1169 if downloader.is_refresher() or self.image_ok(file, 0): 1170 # give priority to newly-downloaded images - unseen_downloads are later 1171 # used with priority over self.prepared 1172 logger.info(lambda: "Adding downloaded file %s to unseen_downloads" % file) 1173 with self.prepared_lock: 1174 unseen = set(downloader.state.get("unseen_downloads", [])) 1175 unseen.add(file) 1176 downloader.state["unseen_downloads"] = [f for f in unseen if os.path.exists(f)] 1177 1178 else: 1179 # image is not ok, but still notify prepare thread that there is a new image - 1180 # it might be "desperate" 1181 self.prepare_event.set() 1182 else: 1183 # register as download failure for this downloader 1184 downloader.state["last_download_failure"] = time.time() 1185 1186 downloader.save_state() 1187 1188 def purge_downloaded(self): 1189 if not self.options.quota_enabled: 1190 return 1191 1192 # Check if we need to compute the download folder size - if it is uninitialized 1193 # or also every now and then to make sure it is in line with actual filesystem state. 1194 # This is a fast-enough operation. 1195 if self.download_folder_size is None or random.random() < 0.05: 1196 self.download_folder_size = Util.get_folder_size(self.real_download_folder) 1197 logger.info( 1198 lambda: "Refreshed download folder size: {} mb".format( 1199 self.download_folder_size / (1024.0 * 1024.0) 1200 ) 1201 ) 1202 1203 mb_quota = self.options.quota_size * 1024 * 1024 1204 if self.download_folder_size > 0.95 * mb_quota: 1205 logger.info( 1206 lambda: "Purging oldest files from download folder {}, current size: {} mb".format( 1207 self.real_download_folder, int(self.download_folder_size / (1024.0 * 1024.0)) 1208 ) 1209 ) 1210 files = [] 1211 for dirpath, dirnames, filenames in os.walk(self.real_download_folder): 1212 for f in filenames: 1213 if Util.is_image(f) or f.endswith(".partial"): 1214 fp = os.path.join(dirpath, f) 1215 files.append((fp, os.path.getsize(fp), os.path.getctime(fp))) 1216 files = sorted(files, key=lambda x: x[2]) 1217 i = 0 1218 while i < len(files) and self.download_folder_size > 0.80 * mb_quota: 1219 file = files[i][0] 1220 if file != self.current: 1221 try: 1222 logger.debug(lambda: "Deleting old file in downloaded: {}".format(file)) 1223 self.remove_from_queues(file) 1224 Util.safe_unlink(file) 1225 self.download_folder_size -= files[i][1] 1226 Util.safe_unlink(file + ".metadata.json") 1227 except Exception: 1228 logger.exception( 1229 lambda: "Could not delete some file while purging download folder: {}".format( 1230 file 1231 ) 1232 ) 1233 i += 1 1234 self.prepare_event.set() 1235 1236 class RefreshLevel: 1237 ALL = 0 1238 FILTERS_AND_TEXTS = 1 1239 TEXTS = 2 1240 CLOCK_ONLY = 3 1241 1242 def set_wp_throttled(self, filename, refresh_level=RefreshLevel.ALL): 1243 if not filename: 1244 logger.warning(lambda: "set_wp_throttled: No wallpaper to set") 1245 return 1246 1247 self.thumbs_manager.mark_active(file=filename, position=self.position) 1248 1249 def _do_set_wp(): 1250 self.do_set_wp(filename, refresh_level) 1251 1252 threading.Timer(0, _do_set_wp).start() 1253 1254 def build_imagemagick_filter_cmd(self, filename, target_file): 1255 if not self.filters: 1256 return None 1257 1258 filter = random.choice(self.filters).strip() 1259 if not filter: 1260 return None 1261 1262 w = Gdk.Screen.get_default().get_width() 1263 h = Gdk.Screen.get_default().get_height() 1264 cmd = "convert %s -scale %dx%d^ " % (shlex.quote(filename), w, h) 1265 1266 logger.info(lambda: "Applying filter: " + filter) 1267 cmd += filter + " " 1268 1269 cmd += shlex.quote(target_file) 1270 cmd = cmd.replace("%FILEPATH%", shlex.quote(filename)) 1271 cmd = cmd.replace("%FILENAME%", shlex.quote(os.path.basename(filename))) 1272 1273 logger.info(lambda: "ImageMagick filter cmd: " + cmd) 1274 return cmd.encode("utf-8") 1275 1276 def build_imagemagick_clock_cmd(self, filename, target_file): 1277 if not (self.options.clock_enabled and self.options.clock_filter.strip()): 1278 return None 1279 1280 w = Gdk.Screen.get_default().get_width() 1281 h = Gdk.Screen.get_default().get_height() 1282 cmd = "convert %s -scale %dx%d^ " % (shlex.quote(filename), w, h) 1283 1284 hoffset, voffset = Util.compute_trimmed_offsets(Util.get_size(filename), (w, h)) 1285 clock_filter = self.options.clock_filter 1286 clock_filter = VarietyWindow.replace_clock_filter_offsets(clock_filter, hoffset, voffset) 1287 clock_filter = self.replace_clock_filter_fonts(clock_filter) 1288 1289 clock_filter = time.strftime( 1290 clock_filter, time.localtime() 1291 ) # this should always be called last 1292 logger.info(lambda: "Applying clock filter: " + clock_filter) 1293 1294 cmd += clock_filter 1295 cmd += " " 1296 cmd += shlex.quote(target_file) 1297 logger.info(lambda: "ImageMagick clock cmd: " + cmd) 1298 return cmd.encode("utf-8") 1299 1300 def replace_clock_filter_fonts(self, clock_filter): 1301 clock_font_name, clock_font_size = Util.gtk_to_fcmatch_font(self.options.clock_font) 1302 date_font_name, date_font_size = Util.gtk_to_fcmatch_font(self.options.clock_date_font) 1303 clock_filter = clock_filter.replace("%CLOCK_FONT_NAME", clock_font_name) 1304 clock_filter = clock_filter.replace("%CLOCK_FONT_SIZE", clock_font_size) 1305 clock_filter = clock_filter.replace("%DATE_FONT_NAME", date_font_name) 1306 clock_filter = clock_filter.replace("%DATE_FONT_SIZE", date_font_size) 1307 return clock_filter 1308 1309 @staticmethod 1310 def replace_clock_filter_offsets(filter, hoffset, voffset): 1311 def hrepl(m): 1312 return str(hoffset + int(m.group(1))) 1313 1314 def vrepl(m): 1315 return str(voffset + int(m.group(1))) 1316 1317 filter = re.sub(r"\[\%HOFFSET\+(\d+)\]", hrepl, filter) 1318 filter = re.sub(r"\[\%VOFFSET\+(\d+)\]", vrepl, filter) 1319 return filter 1320 1321 def refresh_wallpaper(self): 1322 self.set_wp_throttled( 1323 self.current, refresh_level=VarietyWindow.RefreshLevel.FILTERS_AND_TEXTS 1324 ) 1325 1326 def refresh_clock(self): 1327 self.set_wp_throttled(self.current, refresh_level=VarietyWindow.RefreshLevel.CLOCK_ONLY) 1328 1329 def refresh_texts(self): 1330 self.set_wp_throttled(self.current, refresh_level=VarietyWindow.RefreshLevel.TEXTS) 1331 1332 def write_filtered_wallpaper_origin(self, filename): 1333 if not filename: 1334 return 1335 try: 1336 with open( 1337 os.path.join(self.wallpaper_folder, "wallpaper.jpg.txt"), "w", encoding="utf8" 1338 ) as f: 1339 f.write(filename) 1340 except Exception: 1341 logger.exception(lambda: "Cannot write wallpaper.jpg.txt") 1342 1343 def apply_filters(self, to_set, refresh_level): 1344 try: 1345 if self.filters: 1346 # don't run the filter command when the refresh level is clock or quotes only, 1347 # use the previous filtered image otherwise 1348 if ( 1349 refresh_level 1350 in [ 1351 VarietyWindow.RefreshLevel.ALL, 1352 VarietyWindow.RefreshLevel.FILTERS_AND_TEXTS, 1353 ] 1354 or not self.post_filter_filename 1355 ): 1356 self.post_filter_filename = to_set 1357 target_file = os.path.join( 1358 self.wallpaper_folder, "wallpaper-filter-%s.jpg" % Util.random_hash() 1359 ) 1360 cmd = self.build_imagemagick_filter_cmd(to_set, target_file) 1361 if cmd: 1362 result = os.system(cmd) 1363 if result == 0: # success 1364 to_set = target_file 1365 self.post_filter_filename = to_set 1366 else: 1367 logger.warning( 1368 lambda: "Could not execute filter convert command. " 1369 "Missing ImageMagick or bad filter defined? Resultcode: %d" % result 1370 ) 1371 else: 1372 to_set = self.post_filter_filename 1373 return to_set 1374 except Exception: 1375 logger.exception(lambda: "Could not apply filters:") 1376 return to_set 1377 1378 def apply_quote(self, to_set): 1379 try: 1380 if self.options.quotes_enabled and self.quote: 1381 quote_outfile = os.path.join( 1382 self.wallpaper_folder, "wallpaper-quote-%s.jpg" % Util.random_hash() 1383 ) 1384 QuoteWriter.write_quote( 1385 self.quote["quote"], 1386 self.quote.get("author", None), 1387 to_set, 1388 quote_outfile, 1389 self.options, 1390 ) 1391 to_set = quote_outfile 1392 return to_set 1393 except Exception: 1394 logger.exception(lambda: "Could not apply quote:") 1395 return to_set 1396 1397 def apply_clock(self, to_set): 1398 try: 1399 if self.options.clock_enabled: 1400 target_file = os.path.join( 1401 self.wallpaper_folder, "wallpaper-clock-%s.jpg" % Util.random_hash() 1402 ) 1403 cmd = self.build_imagemagick_clock_cmd(to_set, target_file) 1404 result = os.system(cmd) 1405 if result == 0: # success 1406 to_set = target_file 1407 else: 1408 logger.warning( 1409 lambda: "Could not execute clock convert command. " 1410 "Missing ImageMagick or bad filter defined? Resultcode: %d" % result 1411 ) 1412 return to_set 1413 except Exception: 1414 logger.exception(lambda: "Could not apply clock:") 1415 return to_set 1416 1417 def apply_copyto_operation(self, to_set): 1418 if self.options.copyto_enabled: 1419 folder = self.get_actual_copyto_folder() 1420 target_fname = "variety-copied-wallpaper-%s%s" % ( 1421 Util.random_hash(), 1422 os.path.splitext(to_set)[1], 1423 ) 1424 target_file = os.path.join(folder, target_fname) 1425 self.cleanup_old_wallpapers(folder, "variety-copied-wallpaper") 1426 try: 1427 shutil.copy(to_set, target_file) 1428 os.chmod( 1429 target_file, 0o644 1430 ) # Read permissions for everyone, write - for the current user 1431 to_set = target_file 1432 except Exception: 1433 logger.exception( 1434 lambda: "Could not copy file %s to copyto folder %s. " 1435 "Using it from original locations, so LightDM might not be able to use it." 1436 % (to_set, folder) 1437 ) 1438 return to_set 1439 1440 def get_actual_copyto_folder(self, option=None): 1441 option = option or self.options.copyto_folder 1442 if option == "Default": 1443 return ( 1444 Util.get_xdg_pictures_folder() 1445 if not Util.is_home_encrypted() 1446 else "/usr/local/share/backgrounds" 1447 ) 1448 else: 1449 return os.path.normpath(option) 1450 1451 @throttle(seconds=1, trailing_call=True) 1452 def do_set_wp(self, filename, refresh_level=RefreshLevel.ALL): 1453 logger.info(lambda: "Calling do_set_wp with %s, time: %s" % (filename, time.time())) 1454 with self.do_set_wp_lock: 1455 try: 1456 if not os.access(filename, os.R_OK): 1457 logger.info( 1458 lambda: "Missing file or bad permissions, will not use it: " + filename 1459 ) 1460 return 1461 1462 self.write_filtered_wallpaper_origin(filename) 1463 to_set = filename 1464 1465 if filename != self.no_effects_on: 1466 self.no_effects_on = None 1467 to_set = self.apply_filters(to_set, refresh_level) 1468 to_set = self.apply_quote(to_set) 1469 to_set = self.apply_clock(to_set) 1470 to_set = self.apply_copyto_operation(to_set) 1471 1472 self.cleanup_old_wallpapers(self.wallpaper_folder, "wallpaper-", to_set) 1473 self.update_indicator(filename) 1474 self.set_desktop_wallpaper(to_set, filename, refresh_level) 1475 self.current = filename 1476 1477 if self.options.icon == "Current" and self.current: 1478 1479 def _set_icon_to_current(): 1480 if self.ind: 1481 self.ind.set_icon(self.current) 1482 1483 Util.add_mainloop_task(_set_icon_to_current) 1484 1485 if refresh_level == VarietyWindow.RefreshLevel.ALL: 1486 self.last_change_time = time.time() 1487 self.save_last_change_time() 1488 self.save_history() 1489 except Exception: 1490 logger.exception(lambda: "Error while setting wallpaper") 1491 1492 def list_images(self): 1493 return Util.list_files(self.individual_images, self.folders, Util.is_image, max_files=10000) 1494 1495 def select_random_images(self, count): 1496 all_images = list(self.list_images()) 1497 self.image_count = len(all_images) 1498 1499 # add just the first image of each album to the selection, 1500 # otherwise albums will get an enormous part of the screentime, as they act as 1501 # "black holes" - once we start them, we stay there until done 1502 for album in self.albums: 1503 all_images.append(album["images"][0]) 1504 1505 random.shuffle(all_images) 1506 return all_images[:count] 1507 1508 def on_indicator_scroll(self, indicator, steps, direction): 1509 if direction in (Gdk.ScrollDirection.DOWN, Gdk.ScrollDirection.UP): 1510 self.recent_scroll_actions = getattr(self, "recent_scroll_actions", []) 1511 self.recent_scroll_actions = [ 1512 a for a in self.recent_scroll_actions if a[0] > time.time() - 0.3 1513 ] 1514 self.recent_scroll_actions.append((time.time(), steps, direction)) 1515 count_up = sum( 1516 a[1] for a in self.recent_scroll_actions if a[2] == Gdk.ScrollDirection.UP 1517 ) 1518 count_down = sum( 1519 a[1] for a in self.recent_scroll_actions if a[2] == Gdk.ScrollDirection.DOWN 1520 ) 1521 self.on_indicator_scroll_throttled( 1522 Gdk.ScrollDirection.UP if count_up > count_down else Gdk.ScrollDirection.DOWN 1523 ) 1524 1525 @debounce(seconds=0.3) 1526 def on_indicator_scroll_throttled(self, direction): 1527 if direction == Gdk.ScrollDirection.DOWN: 1528 self.next_wallpaper(widget=self) 1529 else: 1530 self.prev_wallpaper(widget=self) 1531 1532 def prev_wallpaper(self, widget=None): 1533 self.auto_changed = widget is None 1534 if self.quotes_engine and self.options.quotes_enabled: 1535 self.quote = self.quotes_engine.prev_quote() 1536 if self.position >= len(self.used) - 1: 1537 return 1538 else: 1539 self.position += 1 1540 self.set_wp_throttled(self.used[self.position]) 1541 1542 def next_wallpaper(self, widget=None, bypass_history=False): 1543 self.auto_changed = widget is None 1544 if self.position > 0 and not bypass_history: 1545 if self.quotes_engine and self.options.quotes_enabled: 1546 self.quote = self.quotes_engine.next_quote() 1547 self.position -= 1 1548 self.set_wp_throttled(self.used[self.position]) 1549 else: 1550 if bypass_history: 1551 self.position = 0 1552 if self.quotes_engine and self.options.quotes_enabled: 1553 self.quotes_engine.bypass_history() 1554 self.change_wallpaper() 1555 1556 def move_to_history_position(self, position): 1557 if 0 <= position < len(self.used): 1558 self.auto_changed = False 1559 self.position = position 1560 self.set_wp_throttled(self.used[self.position]) 1561 else: 1562 logger.warning( 1563 lambda: "Invalid position passed to move_to_history_position, %d, used len is %d" 1564 % (position, len(self.used)) 1565 ) 1566 1567 def show_notification(self, title, message="", icon=None, important=False): 1568 if not icon: 1569 icon = varietyconfig.get_data_file("media", "variety.svg") 1570 1571 if not important: 1572 try: 1573 self.notification.update(title, message, icon) 1574 except AttributeError: 1575 self.notification = Notify.Notification.new(title, message, icon) 1576 self.notification.set_urgency(Notify.Urgency.LOW) 1577 self.notification.show() 1578 else: 1579 # use a separate notification that will not be updated with a non-important message 1580 notification = Notify.Notification.new(title, message, icon) 1581 notification.set_urgency(Notify.Urgency.NORMAL) 1582 notification.show() 1583 1584 def _has_local_sources(self): 1585 return ( 1586 sum(1 for s in self.options.sources if s[0] and s[1] in Options.SourceType.LOCAL_TYPES) 1587 > 0 1588 ) 1589 1590 def change_wallpaper(self, widget=None): 1591 try: 1592 img = None 1593 1594 # check if current is part of an album, and show next image in the album 1595 if self.current: 1596 for album in self.albums: 1597 if os.path.normpath(self.current).startswith(album["path"]): 1598 index = album["images"].index(self.current) 1599 if 0 <= index < len(album["images"]) - 1: 1600 img = album["images"][index + 1] 1601 break 1602 1603 if not img: 1604 with self.prepared_lock: 1605 # with some big probability, use one of the unseen_downloads 1606 if random.random() < self.options.download_preference_ratio: 1607 enabled_unseen_downloads = self._enabled_unseen_downloads() 1608 if enabled_unseen_downloads: 1609 unseen = random.choice(list(enabled_unseen_downloads)) 1610 self.prepared.insert(0, unseen) 1611 1612 for prep in self.prepared: 1613 if prep != self.current and os.access(prep, os.R_OK): 1614 img = prep 1615 try: 1616 self.prepared.remove(img) 1617 except ValueError: 1618 pass 1619 self.prepare_event.set() 1620 break 1621 1622 if not img: 1623 logger.info(lambda: "No images yet in prepared buffer, using some random image") 1624 self.prepare_event.set() 1625 rnd_images = self.select_random_images(3) 1626 rnd_images = [ 1627 f for f in rnd_images if f != self.current or self.is_current_refreshable() 1628 ] 1629 img = rnd_images[0] if rnd_images else None 1630 1631 if not img: 1632 logger.info(lambda: "No images found") 1633 if not self.auto_changed: 1634 if self.has_real_downloaders(): 1635 msg = _("Please add more image sources or wait for some downloads") 1636 else: 1637 msg = _("Please add more image sources") 1638 self.show_notification(_("No more wallpapers"), msg) 1639 return 1640 1641 if self.quotes_engine and self.options.quotes_enabled: 1642 self.quote = self.quotes_engine.change_quote() 1643 1644 self.set_wallpaper(img, auto_changed=self.auto_changed) 1645 except Exception: 1646 logger.exception(lambda: "Could not change wallpaper") 1647 1648 def _enabled_unseen_downloads(self): 1649 # collect the unseen_downloads from the currently enabled downloaders: 1650 enabled_unseen_downloads = set() 1651 for dl in self.downloaders: 1652 for file in dl.state.get("unseen_downloads", []): 1653 if os.path.exists(file): 1654 enabled_unseen_downloads.add(file) 1655 return enabled_unseen_downloads 1656 1657 def _remove_from_unseen(self, file): 1658 for dl in self.downloaders: 1659 unseen = set(dl.state.get("unseen_downloads", [])) 1660 if file in unseen: 1661 unseen.remove(file) 1662 dl.state["unseen_downloads"] = [f for f in unseen if os.path.exists(f)] 1663 dl.save_state() 1664 1665 # trigger download after some interval to reduce resource usage while 1666 # the wallpaper changes 1667 delay_dl_timer = threading.Timer(2, self.trigger_download) 1668 delay_dl_timer.daemon = True 1669 delay_dl_timer.start() 1670 1671 def set_wallpaper(self, img, auto_changed=False): 1672 logger.info(lambda: "Calling set_wallpaper with " + img) 1673 if img == self.current and not self.is_current_refreshable(): 1674 return 1675 if os.access(img, os.R_OK): 1676 at_front = self.position == 0 1677 self.used = self.used[self.position :] 1678 if len(self.used) == 0 or self.used[0] != img: 1679 self.used.insert(0, img) 1680 self.refresh_thumbs_history(img, at_front) 1681 self.position = 0 1682 if len(self.used) > 1000: 1683 self.used = self.used[:1000] 1684 1685 self._remove_from_unseen(img) 1686 1687 self.auto_changed = auto_changed 1688 self.last_change_time = time.time() 1689 self.set_wp_throttled(img) 1690 1691 # Unsplash API requires that we call their download endpoint 1692 # when setting the wallpaper, not when queueing it: 1693 meta = Util.read_metadata(img) 1694 if meta and "sourceType" in meta: 1695 for image_source in Options.IMAGE_SOURCES: 1696 if image_source.get_source_type() == meta["sourceType"]: 1697 1698 def _do_hook(): 1699 image_source.on_image_set_as_wallpaper(img, meta) 1700 1701 threading.Timer(0, _do_hook).start() 1702 else: 1703 logger.warning(lambda: "set_wallpaper called with unaccessible image " + img) 1704 1705 def refresh_thumbs_history(self, added_image, at_front=False): 1706 if self.thumbs_manager.is_showing("history"): 1707 1708 def _add(): 1709 if at_front: 1710 self.thumbs_manager.add_image(added_image) 1711 else: 1712 self.thumbs_manager.show(self.used, type="history") 1713 self.thumbs_manager.pin() 1714 1715 add_timer = threading.Timer(0, _add) 1716 add_timer.start() 1717 1718 def refresh_thumbs_downloads(self, added_image): 1719 self.update_indicator(auto_changed=False) 1720 1721 should_show = added_image not in self.thumbs_manager.images and ( 1722 self.thumbs_manager.is_showing("downloads") 1723 or ( 1724 self.thumbs_manager.get_folders() is not None 1725 and sum( 1726 1 for f in self.thumbs_manager.get_folders() if Util.file_in(added_image, f) 1727 ) 1728 > 0 1729 ) 1730 ) 1731 1732 if should_show: 1733 1734 def _add(): 1735 self.thumbs_manager.add_image(added_image) 1736 1737 add_timer = threading.Timer(0, _add) 1738 add_timer.start() 1739 1740 def on_rating_changed(self, file): 1741 with self.prepared_lock: 1742 self.prepared = [f for f in self.prepared if f != file] 1743 self.prepare_event.set() 1744 self.update_indicator(auto_changed=False) 1745 1746 def image_ok(self, img, fuzziness): 1747 try: 1748 if Util.is_animated_gif(img): 1749 return False 1750 1751 if self.options.min_rating_enabled: 1752 rating = Util.get_rating(img) 1753 if rating is None or rating <= 0 or rating < self.options.min_rating: 1754 return False 1755 1756 if self.options.use_landscape_enabled or self.options.min_size_enabled: 1757 if img in self.image_colors_cache: 1758 width = self.image_colors_cache[img][3] 1759 height = self.image_colors_cache[img][4] 1760 else: 1761 i = PILImage.open(img) 1762 width = i.size[0] 1763 height = i.size[1] 1764 1765 if not self.size_ok(width, height, fuzziness): 1766 return False 1767 1768 if self.options.desired_color_enabled or self.options.lightness_enabled: 1769 if not img in self.image_colors_cache: 1770 dom = DominantColors(img, False) 1771 self.image_colors_cache[img] = dom.get_dominant_colors() 1772 colors = self.image_colors_cache[img] 1773 1774 if self.options.lightness_enabled: 1775 lightness = colors[2] 1776 if self.options.lightness_mode == Options.LightnessMode.DARK: 1777 if lightness >= 75 + fuzziness * 6: 1778 return False 1779 elif self.options.lightness_mode == Options.LightnessMode.LIGHT: 1780 if lightness <= 180 - fuzziness * 6: 1781 return False 1782 else: 1783 logger.warning( 1784 lambda: "Unknown lightness mode: %d", self.options.lightness_mode 1785 ) 1786 1787 if ( 1788 self.options.desired_color_enabled 1789 and self.options.desired_color 1790 and not DominantColors.contains_color( 1791 colors, self.options.desired_color, fuzziness + 2 1792 ) 1793 ): 1794 return False 1795 1796 if self.options.safe_mode: 1797 try: 1798 info = Util.read_metadata(img) 1799 if info.get("sfwRating", 100) < 100: 1800 return False 1801 1802 blacklisted = ( 1803 set(k.lower() for k in info.get("keywords", [])) & SAFE_MODE_BLACKLIST 1804 ) 1805 if len(blacklisted) > 0: 1806 return False 1807 except Exception: 1808 pass 1809 1810 return True 1811 1812 except Exception: 1813 logger.exception(lambda: "Error in image_ok for file %s" % img) 1814 return False 1815 1816 def size_ok(self, width, height, fuzziness=0): 1817 ok = True 1818 1819 if self.options.min_size_enabled: 1820 ok = ok and width >= self.min_width - fuzziness * 100 1821 ok = ok and height >= self.min_height - fuzziness * 70 1822 1823 if self.options.use_landscape_enabled: 1824 ok = ok and width > height 1825 1826 return ok 1827 1828 def open_folder(self, widget=None, file=None): 1829 if not file: 1830 file = self.current 1831 if file: 1832 subprocess.Popen(["xdg-open", os.path.dirname(file)]) 1833 1834 def open_file(self, widget=None, file=None): 1835 if not file: 1836 file = self.current 1837 if file: 1838 subprocess.Popen(["xdg-open", os.path.realpath(file)]) 1839 1840 def on_show_origin(self, widget=None): 1841 if self.url: 1842 logger.info(lambda: "Opening url: " + self.url) 1843 webbrowser.open_new_tab(self.url) 1844 else: 1845 self.open_folder() 1846 1847 def on_show_author(self, widget=None): 1848 if hasattr(self, "author_url") and self.author_url: 1849 logger.info(lambda: "Opening url: " + self.author_url) 1850 webbrowser.open_new_tab(self.author_url) 1851 1852 def get_source(self, file=None): 1853 if not file: 1854 file = self.current 1855 if not file: 1856 return None 1857 1858 prioritized_sources = [] 1859 prioritized_sources.extend( 1860 s for s in self.options.sources if s[0] and s[1] == Options.SourceType.IMAGE 1861 ) 1862 prioritized_sources.extend( 1863 s for s in self.options.sources if s[0] and s[1] == Options.SourceType.FOLDER 1864 ) 1865 prioritized_sources.extend( 1866 s 1867 for s in self.options.sources 1868 if s[0] and s[1] in Options.get_downloader_source_types() 1869 ) 1870 prioritized_sources.extend( 1871 s for s in self.options.sources if s[0] and s[1] == Options.SourceType.FETCHED 1872 ) 1873 prioritized_sources.extend( 1874 s for s in self.options.sources if s[0] and s[1] == Options.SourceType.FAVORITES 1875 ) 1876 prioritized_sources.extend(s for s in self.options.sources if s not in prioritized_sources) 1877 1878 if len(prioritized_sources) != len(self.options.sources): 1879 logger.error( 1880 lambda: "len(prioritized_sources) != len(self.options.sources): %d, %d, %s, %s" 1881 % ( 1882 len(prioritized_sources), 1883 len(self.options.sources), 1884 prioritized_sources, 1885 self.options.sources, 1886 ) 1887 ) 1888 1889 file_normpath = os.path.normpath(file) 1890 for s in prioritized_sources: 1891 try: 1892 if s[1] == Options.SourceType.IMAGE: 1893 if os.path.normpath(s[2]) == file_normpath: 1894 return s 1895 elif file_normpath.startswith(Util.folderpath(self.get_folder_of_source(s))): 1896 return s 1897 except Exception: 1898 # probably exception while creating the downloader, ignore, continue searching 1899 pass 1900 1901 return None 1902 1903 def focus_in_preferences(self, widget=None, file=None): 1904 if not file: 1905 file = self.current 1906 source = self.get_source(file) 1907 if source is None: 1908 self.show_notification(_("Current wallpaper is not in the image sources")) 1909 else: 1910 self.on_mnu_preferences_activate() 1911 self.get_preferences_dialog().focus_source_and_image(source, file) 1912 1913 def move_or_copy_file(self, file, to, to_name, operation): 1914 is_move = operation == shutil.move 1915 try: 1916 if file != to: 1917 operation(file, to) 1918 try: 1919 operation(file + ".metadata.json", to) 1920 except Exception: 1921 pass 1922 logger.info(lambda: ("Moved %s to %s" if is_move else "Copied %s to %s") % (file, to)) 1923 # self.show_notification(("Moved %s to %s" if is_move else "Copied %s to %s") % (os.path.basename(file), to_name)) 1924 return True 1925 except Exception as err: 1926 if str(err).find("already exists") > 0: 1927 if operation == shutil.move: 1928 try: 1929 os.unlink(file) 1930 # self.show_notification(op, op + " " + os.path.basename(file) + " to " + to_name) 1931 return True 1932 except Exception: 1933 logger.exception(lambda: "Cannot unlink " + file) 1934 else: 1935 return True 1936 1937 logger.exception(lambda: "Could not move/copy to " + to) 1938 if is_move: 1939 msg = ( 1940 _( 1941 "Could not move to %s. You probably don't have permissions to move this file." 1942 ) 1943 % to 1944 ) 1945 else: 1946 msg = ( 1947 _( 1948 "Could not copy to %s. You probably don't have permissions to copy this file." 1949 ) 1950 % to 1951 ) 1952 dialog = Gtk.MessageDialog( 1953 self, Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING, Gtk.ButtonsType.OK, msg 1954 ) 1955 self.dialogs.append(dialog) 1956 dialog.set_title("Move failed" if is_move else "Copy failed") 1957 dialog.run() 1958 dialog.destroy() 1959 self.dialogs.remove(dialog) 1960 return False 1961 1962 def move_to_trash(self, widget=None, file=None): 1963 try: 1964 if not file: 1965 file = self.current 1966 if not file: 1967 return 1968 if self.url: 1969 self.ban_url(self.url) 1970 1971 if not os.access(file, os.W_OK): 1972 self.show_notification( 1973 _("Cannot delete"), 1974 _("You don't have permissions to delete %s to Trash.") % file, 1975 ) 1976 else: 1977 if self.current == file: 1978 self.next_wallpaper(widget) 1979 1980 self.remove_from_queues(file) 1981 self.prepare_event.set() 1982 1983 self.thumbs_manager.remove_image(file) 1984 1985 def _go(): 1986 try: 1987 gio_file = Gio.File.new_for_path(file) 1988 ok = gio_file.trash() 1989 except: 1990 logger.exception("Gio.File.trash failed with exception") 1991 ok = False 1992 1993 if not ok: 1994 logger.error("Gio.File.trash failed") 1995 self.show_notification( 1996 _("Cannot delete"), 1997 _("Deleting to trash failed, check variety.log for more information."), 1998 ) 1999 2000 Util.add_mainloop_task(_go) 2001 except Exception: 2002 logger.exception(lambda: "Exception in move_to_trash") 2003 2004 def ban_url(self, url): 2005 try: 2006 self.banned.add(url) 2007 with open(os.path.join(self.config_folder, "banned.txt"), "a", encoding="utf8") as f: 2008 f.write(url + "\n") 2009 except Exception: 2010 logger.exception(lambda: "Could not ban URL") 2011 2012 def remove_from_queues(self, file): 2013 self.position = max( 2014 0, self.position - sum(1 for f in self.used[: self.position] if f == file) 2015 ) 2016 self.used = [f for f in self.used if f != file] 2017 self._remove_from_unseen(file) 2018 with self.prepared_lock: 2019 self.prepared = [f for f in self.prepared if f != file] 2020 2021 def remove_folder_from_queues(self, folder): 2022 self.position = max( 2023 0, self.position - sum(1 for f in self.used[: self.position] if Util.file_in(f, folder)) 2024 ) 2025 self.used = [f for f in self.used if not Util.file_in(f, folder)] 2026 with self.prepared_lock: 2027 self.prepared = [f for f in self.prepared if not Util.file_in(f, folder)] 2028 2029 def copy_to_favorites(self, widget=None, file=None): 2030 try: 2031 if not file: 2032 file = self.current 2033 if not file: 2034 return 2035 if os.access(file, os.R_OK) and not self.is_in_favorites(file): 2036 self.move_or_copy_file( 2037 file, self.options.favorites_folder, "favorites", shutil.copy 2038 ) 2039 self.update_indicator(auto_changed=False) 2040 self.report_image_favorited(file) 2041 except Exception: 2042 logger.exception(lambda: "Exception in copy_to_favorites") 2043 2044 def move_to_favorites(self, widget=None, file=None): 2045 try: 2046 if not file: 2047 file = self.current 2048 if not file: 2049 return 2050 if os.access(file, os.R_OK) and not self.is_in_favorites(file): 2051 operation = shutil.move if os.access(file, os.W_OK) else shutil.copy 2052 ok = self.move_or_copy_file( 2053 file, self.options.favorites_folder, "favorites", operation 2054 ) 2055 if ok: 2056 new_file = os.path.join(self.options.favorites_folder, os.path.basename(file)) 2057 self.used = [(new_file if f == file else f) for f in self.used] 2058 with self.prepared_lock: 2059 self.prepared = [(new_file if f == file else f) for f in self.prepared] 2060 self.prepare_event.set() 2061 if self.current == file: 2062 self.current = new_file 2063 if self.no_effects_on == file: 2064 self.no_effects_on = new_file 2065 self.set_wp_throttled(new_file) 2066 self.report_image_favorited(new_file) 2067 except Exception: 2068 logger.exception(lambda: "Exception in move_to_favorites") 2069 2070 def report_image_favorited(self, img): 2071 meta = Util.read_metadata(img) 2072 if meta and "sourceType" in meta: 2073 for image_source in Options.IMAGE_SOURCES: 2074 if image_source.get_source_type() == meta["sourceType"]: 2075 2076 def _do_hook(): 2077 image_source.on_image_favorited(img, meta) 2078 2079 threading.Timer(0, _do_hook).start() 2080 2081 def determine_favorites_operation(self, file=None): 2082 if not file: 2083 file = self.current 2084 if not file: 2085 return None 2086 2087 if self.is_in_favorites(file): 2088 return "favorite" 2089 2090 if not os.access(file, os.W_OK): 2091 return "copy" 2092 2093 file_normpath = os.path.normpath(file) 2094 for pair in self.options.favorites_operations: 2095 folder = pair[0] 2096 folder_lower = folder.lower().strip() 2097 if folder_lower == "downloaded": 2098 folder = self.real_download_folder 2099 elif folder_lower == "fetched": 2100 folder = self.options.fetched_folder 2101 elif folder_lower == "others": 2102 folder = "/" 2103 2104 folder = Util.folderpath(folder) 2105 2106 if file_normpath.startswith(folder): 2107 op = pair[1].lower().strip() 2108 return op if op in ("copy", "move", "both") else "copy" 2109 2110 return "copy" 2111 2112 @on_gtk 2113 def on_quit(self, widget=None): 2114 logger.info(lambda: "Quitting") 2115 if self.running: 2116 self.running = False 2117 2118 logger.debug(lambda: "Trying to destroy all dialogs") 2119 for d in self.dialogs + [self.preferences_dialog, self.about]: 2120 logger.debug(lambda: "Trying to destroy dialog %s" % d) 2121 try: 2122 if d: 2123 d.destroy() 2124 except Exception: 2125 logger.exception(lambda: "Could not destroy dialog") 2126 2127 for e in self.events: 2128 e.set() 2129 2130 try: 2131 if self.quotes_engine: 2132 logger.debug(lambda: "Trying to stop quotes engine") 2133 self.quotes_engine.quit() 2134 except Exception: 2135 logger.exception(lambda: "Could not stop quotes engine") 2136 2137 if self.options.clock_enabled or self.options.quotes_enabled: 2138 self.options.clock_enabled = False 2139 self.options.quotes_enabled = False 2140 if self.current: 2141 logger.debug(lambda: "Cleaning up clock & quotes") 2142 Util.add_mainloop_task( 2143 lambda: self.do_set_wp(self.current, VarietyWindow.RefreshLevel.TEXTS) 2144 ) 2145 2146 Util.start_force_exit_thread(15) 2147 logger.debug(lambda: "OK, waiting for other loops to finish") 2148 logger.debug(lambda: "Remaining threads: ") 2149 for t in threading.enumerate(): 2150 logger.debug(lambda: "%s, %s" % (t.name, getattr(t, "_Thread__target", None))) 2151 Util.add_mainloop_task(Gtk.main_quit) 2152 2153 @on_gtk 2154 def first_run(self, fr_file): 2155 if not self.running: 2156 return 2157 2158 with open(fr_file, "w") as f: 2159 f.write(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) 2160 2161 self.create_autostart_entry() 2162 self.on_mnu_preferences_activate() 2163 2164 def write_current_version(self): 2165 current_version = varietyconfig.get_version() 2166 logger.info(lambda: "Writing current version %s to .version" % current_version) 2167 with open(os.path.join(self.config_folder, ".version"), "w") as f: 2168 f.write(current_version) 2169 2170 def perform_upgrade(self): 2171 try: 2172 current_version = varietyconfig.get_version() 2173 2174 if not os.path.exists(os.path.join(self.config_folder, ".firstrun")): 2175 # running for the first time 2176 last_version = current_version 2177 self.write_current_version() 2178 else: 2179 try: 2180 with open(os.path.join(self.config_folder, ".version")) as f: 2181 last_version = f.read().strip() 2182 except Exception: 2183 last_version = ( 2184 "0.4.12" 2185 ) # this is the last release that did not have the .version file 2186 2187 logger.info( 2188 lambda: "Last run version was %s or earlier, current version is %s" 2189 % (last_version, current_version) 2190 ) 2191 2192 if Util.compare_versions(last_version, "0.4.13") < 0: 2193 logger.info(lambda: "Performing upgrade to 0.4.13") 2194 try: 2195 # mark the current download folder as a valid download folder 2196 options = Options() 2197 options.read() 2198 logger.info( 2199 lambda: "Writing %s to current download folder %s" 2200 % (DL_FOLDER_FILE, options.download_folder) 2201 ) 2202 Util.makedirs(options.download_folder) 2203 dl_folder_file = os.path.join(options.download_folder, DL_FOLDER_FILE) 2204 if not os.path.exists(dl_folder_file): 2205 with open(dl_folder_file, "w") as f: 2206 f.write(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())) 2207 except Exception: 2208 logger.exception( 2209 lambda: "Could not create %s in download folder" % DL_FOLDER_FILE 2210 ) 2211 2212 if Util.compare_versions(last_version, "0.4.14") < 0: 2213 logger.info(lambda: "Performing upgrade to 0.4.14") 2214 2215 # Current wallpaper is now stored in wallpaper subfolder, remove old artefacts: 2216 walltxt = os.path.join(self.config_folder, "wallpaper.jpg.txt") 2217 if os.path.exists(walltxt): 2218 try: 2219 logger.info(lambda: "Moving %s to %s" % (walltxt, self.wallpaper_folder)) 2220 shutil.move(walltxt, self.wallpaper_folder) 2221 except Exception: 2222 logger.exception(lambda: "Could not move wallpaper.jpg.txt") 2223 2224 for suffix in ("filter", "clock", "quote"): 2225 file = os.path.join(self.config_folder, "wallpaper-%s.jpg" % suffix) 2226 if os.path.exists(file): 2227 logger.info(lambda: "Deleting unneeded file " + file) 2228 Util.safe_unlink(file) 2229 2230 if Util.compare_versions(last_version, "0.8.0") < 0: 2231 logger.info(lambda: "Performing upgrade to 0.8.0") 2232 options = Options() 2233 options.read() 2234 for source in options.sources: 2235 source[2] = source[2].replace("alpha.wallhaven.cc", "wallhaven.cc") 2236 options.write() 2237 2238 if Util.compare_versions(last_version, "0.8.2") < 0: 2239 logger.info(lambda: "Performing upgrade to 0.8.2") 2240 options = Options() 2241 options.read() 2242 if not "Urban Dictionary" in options.quotes_disabled_sources: 2243 options.quotes_disabled_sources.append("Urban Dictionary") 2244 options.write() 2245 2246 if Util.compare_versions(last_version, "0.8.3") < 0: 2247 logger.info(lambda: "Performing upgrade to 0.8.3") 2248 options = Options() 2249 options.read() 2250 options.sources = [source for source in options.sources if source[1] != "earth"] 2251 options.write() 2252 2253 # Perform on every upgrade to an newer version: 2254 if Util.compare_versions(last_version, current_version) < 0: 2255 self.write_current_version() 2256 2257 # Upgrade set and get_wallpaper scripts 2258 def upgrade_script(script, outdated_md5): 2259 try: 2260 script_file = os.path.join(self.scripts_folder, script) 2261 if ( 2262 not os.path.exists(script_file) 2263 or Util.md5file(script_file) in outdated_md5 2264 ): 2265 logger.info( 2266 lambda: "Outdated %s file, copying it from %s" 2267 % (script, varietyconfig.get_data_file("scripts", script)) 2268 ) 2269 shutil.copy( 2270 varietyconfig.get_data_file("scripts", script), self.scripts_folder 2271 ) 2272 except Exception: 2273 logger.exception(lambda: "Could not upgrade script " + script) 2274 2275 upgrade_script("set_wallpaper", VarietyWindow.OUTDATED_SET_WP_SCRIPTS) 2276 upgrade_script("get_wallpaper", VarietyWindow.OUTDATED_GET_WP_SCRIPTS) 2277 2278 # Upgrade the autostart entry, if there is one 2279 if os.path.exists(get_autostart_file_path()): 2280 logger.info(lambda: "Updating Variety autostart desktop entry") 2281 self.create_autostart_entry() 2282 2283 except Exception: 2284 logger.exception(lambda: "Error during version upgrade. Continuing.") 2285 2286 def show_welcome_dialog(self): 2287 dialog = WelcomeDialog() 2288 2289 def _on_continue(button): 2290 dialog.destroy() 2291 self.dialogs.remove(dialog) 2292 2293 dialog.ui.continue_button.connect("clicked", _on_continue) 2294 self.dialogs.append(dialog) 2295 dialog.run() 2296 dialog.destroy() 2297 2298 def show_privacy_dialog(self): 2299 dialog = PrivacyNoticeDialog() 2300 2301 def _on_accept(*args): 2302 dialog.destroy() 2303 self.dialogs.remove(dialog) 2304 2305 def _on_close(*args): 2306 # At this point we shouldn't have much to clean up yet! 2307 sys.exit(1) 2308 2309 dialog.ui.accept_button.connect("clicked", _on_accept) 2310 dialog.ui.reject_button.connect("clicked", _on_close) 2311 dialog.connect("delete-event", _on_close) 2312 dialog.ui.accept_button.grab_focus() 2313 self.dialogs.append(dialog) 2314 dialog.run() 2315 2316 def edit_prefs_file(self, widget=None): 2317 dialog = Gtk.MessageDialog( 2318 self, 2319 Gtk.DialogFlags.DESTROY_WITH_PARENT, 2320 Gtk.MessageType.INFO, 2321 Gtk.ButtonsType.OK, 2322 _( 2323 "I will open an editor with the config file and apply the changes after you save and close the editor." 2324 ), 2325 ) 2326 self.dialogs.append(dialog) 2327 dialog.set_title("Edit config file") 2328 dialog.run() 2329 dialog.destroy() 2330 self.dialogs.remove(dialog) 2331 subprocess.call(["gedit", os.path.join(self.config_folder, "variety.conf")]) 2332 self.reload_config() 2333 2334 def on_pause_resume(self, widget=None, change_enabled=None): 2335 if change_enabled is None: 2336 self.options.change_enabled = not self.options.change_enabled 2337 else: 2338 self.options.change_enabled = change_enabled 2339 2340 if self.preferences_dialog: 2341 self.preferences_dialog.ui.change_enabled.set_active(self.options.change_enabled) 2342 2343 self.options.write() 2344 self.update_indicator(auto_changed=False) 2345 self.change_event.set() 2346 2347 def on_safe_mode_toggled(self, widget=None, safe_mode=None): 2348 if safe_mode is None: 2349 self.options.safe_mode = not self.options.safe_mode 2350 else: 2351 self.options.safe_mode = safe_mode 2352 2353 if self.preferences_dialog: 2354 self.preferences_dialog.ui.safe_mode.set_active(self.options.safe_mode) 2355 2356 self.options.write() 2357 self.update_indicator(auto_changed=False) 2358 self.clear_prepared_queue() 2359 2360 def process_command(self, arguments, initial_run): 2361 try: 2362 arguments = [str(arg) for arg in arguments] 2363 logger.info(lambda: "Received command: " + str(arguments)) 2364 2365 options, args = parse_options(arguments, report_errors=False) 2366 2367 if options.quit: 2368 self.on_quit() 2369 return 2370 2371 if args: 2372 logger.info(lambda: "Treating free arguments as urls: " + str(args)) 2373 if not initial_run: 2374 self.process_urls(args) 2375 else: 2376 2377 def _process_urls(): 2378 self.process_urls(args) 2379 2380 GObject.timeout_add(5000, _process_urls) 2381 2382 if options.set_options: 2383 try: 2384 Options.set_options(options.set_options) 2385 if not initial_run: 2386 self.reload_config() 2387 except Exception: 2388 logger.exception(lambda: "Could not read/write configuration:") 2389 2390 def _process_command(): 2391 if not initial_run: 2392 if options.trash: 2393 self.move_to_trash() 2394 elif options.favorite: 2395 self.copy_to_favorites() 2396 elif options.movefavorite: 2397 self.move_to_favorites() 2398 2399 if options.set_wallpaper: 2400 self.set_wallpaper(options.set_wallpaper) 2401 elif options.fast_forward: 2402 self.next_wallpaper(bypass_history=True) 2403 elif options.next: 2404 self.next_wallpaper() 2405 elif options.previous: 2406 self.prev_wallpaper() 2407 2408 if options.pause: 2409 self.on_pause_resume(change_enabled=False) 2410 elif options.resume: 2411 self.on_pause_resume(change_enabled=True) 2412 elif options.toggle_pause: 2413 self.on_pause_resume() 2414 2415 if options.toggle_no_effects: 2416 self.toggle_no_effects(not bool(self.no_effects_on)) 2417 2418 if options.history: 2419 self.show_hide_history() 2420 if options.downloads: 2421 self.show_hide_downloads() 2422 if options.selector: 2423 self.show_hide_wallpaper_selector() 2424 if options.preferences: 2425 self.on_mnu_preferences_activate() 2426 2427 if options.quotes_fast_forward: 2428 self.next_quote(bypass_history=True) 2429 elif options.quotes_next: 2430 self.next_quote() 2431 elif options.quotes_previous: 2432 self.prev_quote() 2433 2434 if options.quotes_toggle_pause: 2435 self.on_quotes_pause_resume() 2436 2437 if options.quotes_save_favorite: 2438 self.quote_save_to_favorites() 2439 2440 GObject.timeout_add(3000 if initial_run else 1, _process_command) 2441 2442 return self.current if options.show_current else "" 2443 except Exception: 2444 logger.exception(lambda: "Could not process passed command") 2445 2446 @on_gtk 2447 def update_indicator_icon(self): 2448 if self.options.icon != "None": 2449 if self.ind is None: 2450 logger.info(lambda: "Creating indicator") 2451 self.ind, self.indicator, self.status_icon = indicator.new_application_indicator( 2452 self 2453 ) 2454 else: 2455 self.ind.set_visible(True) 2456 2457 if self.options.icon == "Current": 2458 self.ind.set_icon(self.current) 2459 else: 2460 self.ind.set_icon(self.options.icon) 2461 else: 2462 if self.ind is not None: 2463 self.ind.set_visible(False) 2464 2465 def process_urls(self, urls, verbose=True): 2466 def fetch(): 2467 try: 2468 Util.makedirs(self.options.fetched_folder) 2469 2470 for url in urls: 2471 if not self.running: 2472 return 2473 2474 if url.startswith(("variety://", "vrty://")): 2475 self.process_variety_url(url) 2476 continue 2477 2478 is_local = os.path.exists(url) 2479 2480 if is_local: 2481 if not (os.path.isfile(url) and Util.is_image(url)): 2482 self.show_notification(_("Not an image"), url) 2483 continue 2484 2485 file = url 2486 local_name = os.path.basename(file) 2487 self.show_notification( 2488 _("Added to queue"), 2489 local_name + "\n" + _("Press Next to see it"), 2490 icon=file, 2491 ) 2492 else: 2493 file = ImageFetcher.fetch( 2494 url, 2495 self.options.fetched_folder, 2496 progress_reporter=self.show_notification, 2497 verbose=verbose, 2498 ) 2499 if file: 2500 self.show_notification( 2501 _("Fetched"), 2502 os.path.basename(file) + "\n" + _("Press Next to see it"), 2503 icon=file, 2504 ) 2505 2506 if file: 2507 self.register_downloaded_file(file) 2508 with self.prepared_lock: 2509 logger.info( 2510 lambda: "Adding fetched file %s to used queue immediately after current file" 2511 % file 2512 ) 2513 2514 try: 2515 if self.used[self.position] != file and ( 2516 self.position <= 0 or self.used[self.position - 1] != file 2517 ): 2518 at_front = self.position == 0 2519 self.used.insert(self.position, file) 2520 self.position += 1 2521 self.thumbs_manager.mark_active( 2522 file=self.used[self.position], position=self.position 2523 ) 2524 self.refresh_thumbs_history(file, at_front) 2525 except IndexError: 2526 self.used.insert(self.position, file) 2527 self.position += 1 2528 2529 except Exception: 2530 logger.exception(lambda: "Exception in process_urls") 2531 2532 fetch_thread = threading.Thread(target=fetch) 2533 fetch_thread.daemon = True 2534 fetch_thread.start() 2535 2536 def process_variety_url(self, url): 2537 try: 2538 logger.info(lambda: "Processing variety url %s" % url) 2539 2540 # make the url urlparse-friendly: 2541 url = url.replace("variety://", "http://") 2542 url = url.replace("vrty://", "http://") 2543 2544 parts = urllib.parse.urlparse(url) 2545 command = parts.netloc 2546 args = urllib.parse.parse_qs(parts.query) 2547 2548 if command == "add-source": 2549 source_type = args["type"][0].lower() 2550 if not source_type in Options.get_all_supported_source_types(): 2551 self.show_notification( 2552 _("Unsupported source type"), 2553 _("Are you running the most recent version of Variety?"), 2554 ) 2555 return 2556 2557 def _add(): 2558 newly_added = self.preferences_dialog.add_sources( 2559 source_type, [args["location"][0]] 2560 ) 2561 self.preferences_dialog.delayed_apply() 2562 if newly_added == 1: 2563 self.show_notification(_("New image source added")) 2564 else: 2565 self.show_notification(_("Image source already exists, enabling it")) 2566 2567 Util.add_mainloop_task(_add) 2568 2569 elif command == "set-wallpaper": 2570 image_url = args["image_url"][0] 2571 origin_url = args["origin_url"][0] 2572 source_type = args.get("source_type", [None])[0] 2573 source_location = args.get("source_location", [None])[0] 2574 source_name = args.get("source_name", [None])[0] 2575 extra_metadata = {} 2576 2577 image = ImageFetcher.fetch( 2578 image_url, 2579 self.options.fetched_folder, 2580 origin_url=origin_url, 2581 source_type=source_type, 2582 source_location=source_location, 2583 source_name=source_name, 2584 extra_metadata=extra_metadata, 2585 progress_reporter=self.show_notification, 2586 verbose=True, 2587 ) 2588 if image: 2589 self.register_downloaded_file(image) 2590 self.show_notification( 2591 _("Fetched and applied"), os.path.basename(image), icon=image 2592 ) 2593 self.set_wallpaper(image, False) 2594 2595 elif command == "test-variety-link": 2596 self.show_notification(_("It works!"), _("Yay, Variety links work. Great!")) 2597 2598 else: 2599 self.show_notification( 2600 _("Unsupported command"), 2601 _("Are you running the most recent version of Variety?"), 2602 ) 2603 except: 2604 self.show_notification( 2605 _("Could not process the given variety:// URL"), 2606 _("Run with logging enabled to see details"), 2607 ) 2608 logger.exception(lambda: "Exception in process_variety_url") 2609 2610 def get_desktop_wallpaper(self): 2611 try: 2612 script = os.path.join(self.scripts_folder, "get_wallpaper") 2613 2614 file = None 2615 2616 if os.access(script, os.X_OK): 2617 logger.debug(lambda: "Running get_wallpaper script") 2618 try: 2619 output = subprocess.check_output(script).decode().strip() 2620 if output: 2621 file = output 2622 except subprocess.CalledProcessError: 2623 logger.exception(lambda: "Exception when calling get_wallpaper script") 2624 else: 2625 logger.warning( 2626 lambda: "get_wallpaper script is missing or not executable: " + script 2627 ) 2628 2629 if not file and self.gsettings: 2630 file = self.gsettings.get_string("picture-uri") 2631 2632 if not file: 2633 return None 2634 2635 if file[0] == file[-1] == "'" or file[0] == file[-1] == '"': 2636 file = file[1:-1] 2637 2638 file = file.replace("file://", "") 2639 return file 2640 except Exception: 2641 logger.exception(lambda: "Could not get current wallpaper") 2642 return None 2643 2644 def cleanup_old_wallpapers(self, folder, prefix, new_wallpaper=None): 2645 try: 2646 current_wallpaper = self.get_desktop_wallpaper() 2647 for name in os.listdir(folder): 2648 file = os.path.join(folder, name) 2649 if ( 2650 file != current_wallpaper 2651 and file != new_wallpaper 2652 and file != self.post_filter_filename 2653 and name.startswith(prefix) 2654 and Util.is_image(name) 2655 ): 2656 logger.debug(lambda: "Removing old wallpaper %s" % file) 2657 Util.safe_unlink(file) 2658 except Exception: 2659 logger.exception(lambda: "Cannot remove all old wallpaper files from %s:" % folder) 2660 2661 def set_desktop_wallpaper(self, wallpaper, original_file, refresh_level): 2662 script = os.path.join(self.scripts_folder, "set_wallpaper") 2663 if os.access(script, os.X_OK): 2664 auto = ( 2665 "manual" 2666 if not self.auto_changed 2667 else ("auto" if refresh_level == VarietyWindow.RefreshLevel.ALL else "refresh") 2668 ) 2669 logger.debug( 2670 lambda: "Running set_wallpaper script with parameters: %s, %s, %s" 2671 % (wallpaper, auto, original_file) 2672 ) 2673 try: 2674 subprocess.check_call( 2675 ["timeout", "--kill-after=5", "10", script, wallpaper, auto, original_file] 2676 ) 2677 except subprocess.CalledProcessError as e: 2678 if e.returncode == 124: 2679 logger.error(lambda: "Timeout while running set_wallpaper script, killed") 2680 logger.exception( 2681 lambda: "Exception when calling set_wallpaper script: %d" % e.returncode 2682 ) 2683 else: 2684 logger.error(lambda: "set_wallpaper script is missing or not executable: " + script) 2685 if self.gsettings: 2686 self.gsettings.set_string("picture-uri", "file://" + wallpaper) 2687 self.gsettings.apply() 2688 2689 def show_hide_history(self, widget=None): 2690 if self.thumbs_manager.is_showing("history"): 2691 self.thumbs_manager.hide(force=True) 2692 else: 2693 self.thumbs_manager.show(self.used, type="history") 2694 self.thumbs_manager.pin() 2695 self.update_indicator(auto_changed=False) 2696 2697 def show_hide_downloads(self, widget=None): 2698 if self.thumbs_manager.is_showing("downloads"): 2699 self.thumbs_manager.hide(force=True) 2700 else: 2701 downloaded = list( 2702 Util.list_files( 2703 files=[], 2704 folders=[self.real_download_folder], 2705 filter_func=Util.is_image, 2706 randomize=False, 2707 ) 2708 ) 2709 downloaded = sorted(downloaded, key=lambda f: os.stat(f).st_mtime, reverse=True) 2710 self.thumbs_manager.show(downloaded, type="downloads") 2711 self.thumbs_manager.pin() 2712 self.update_indicator(auto_changed=False) 2713 2714 def show_hide_wallpaper_selector(self, widget=None): 2715 pref_dialog = self.get_preferences_dialog() 2716 if self.thumbs_manager.is_showing("selector"): 2717 self.thumbs_manager.hide(force=True) 2718 else: 2719 rows = [r for r in pref_dialog.ui.sources.get_model() if r[0]] 2720 2721 def _go(): 2722 pref_dialog.show_thumbs(rows, pin=True, thumbs_type="selector") 2723 2724 threading.Timer(0, _go).start() 2725 2726 def save_last_change_time(self): 2727 with open(os.path.join(self.config_folder, ".last_change_time"), "w") as f: 2728 f.write(str(self.last_change_time)) 2729 2730 def load_last_change_time(self): 2731 now = time.time() 2732 self.last_change_time = now 2733 2734 # take persisted last_change_time into consideration only if the change interval is more than 6 hours: 2735 # thus users who change often won't have the wallpaper changed practically on every start, 2736 # and users who change rarely will still have their wallpaper changed sometimes even if Variety or the computer 2737 # does not run all the time 2738 if self.options.change_interval >= 6 * 60 * 60: 2739 try: 2740 with open(os.path.join(self.config_folder, ".last_change_time")) as f: 2741 self.last_change_time = float(f.read().strip()) 2742 if self.last_change_time > now: 2743 logger.warning( 2744 lambda: "Persisted last_change_time after current time, setting to current time" 2745 ) 2746 self.last_change_time = now 2747 logger.info( 2748 lambda: "Change interval >= 6 hours, using persisted last_change_time " 2749 + str(self.last_change_time) 2750 ) 2751 logger.info( 2752 lambda: "Still to wait: %d seconds" 2753 % max(0, self.options.change_interval - (time.time() - self.last_change_time)) 2754 ) 2755 except Exception: 2756 logger.info(lambda: "Could not read last change time, setting it to current time") 2757 self.last_change_time = now 2758 else: 2759 logger.info( 2760 lambda: "Change interval < 6 hours, ignore persisted last_change_time, " 2761 "wait initially the whole interval: " + str(self.options.change_interval) 2762 ) 2763 2764 def save_history(self): 2765 try: 2766 start = max(0, self.position - 100) # TODO do we want to remember forward history? 2767 end = min(self.position + 100, len(self.used)) 2768 to_save = self.used[start:end] 2769 with open(os.path.join(self.config_folder, "history.txt"), "w", encoding="utf8") as f: 2770 f.write("%d\n" % (self.position - start)) 2771 for file in to_save: 2772 f.write(file + "\n") 2773 except Exception: 2774 logger.exception(lambda: "Could not save history") 2775 2776 def load_history(self): 2777 self.used = [] 2778 self.position = 0 2779 self.no_effects_on = None 2780 2781 try: 2782 with open(os.path.join(self.config_folder, "history.txt"), "r", encoding="utf8") as f: 2783 lines = list(f) 2784 self.position = int(lines[0].strip()) 2785 for i, line in enumerate(lines[1:]): 2786 if os.access(line.strip(), os.R_OK): 2787 self.used.append(line.strip()) 2788 elif i <= self.position: 2789 self.position = max(0, self.position - 1) 2790 except Exception: 2791 logger.warning(lambda: "Could not load history file, continuing without it, no worries") 2792 2793 current = self.get_desktop_wallpaper() 2794 if current: 2795 if os.path.normpath(os.path.dirname(current)) == os.path.normpath( 2796 self.wallpaper_folder 2797 ) or os.path.basename(current).startswith("variety-copied-wallpaper-"): 2798 2799 try: 2800 with open( 2801 os.path.join(self.wallpaper_folder, "wallpaper.jpg.txt"), encoding="utf8" 2802 ) as f: 2803 current = f.read().strip() 2804 except Exception: 2805 logger.exception(lambda: "Cannot read wallpaper.jpg.txt") 2806 2807 self.current = current 2808 if self.current and ( 2809 self.position >= len(self.used) or current != self.used[self.position] 2810 ): 2811 self.used.insert(0, self.current) 2812 self.position = 0 2813 2814 def disable_quotes(self, widget=None): 2815 self.options.quotes_enabled = False 2816 self.quote = None 2817 2818 if self.preferences_dialog: 2819 self.preferences_dialog.ui.quotes_enabled.set_active(False) 2820 2821 self.options.write() 2822 self.update_indicator(auto_changed=False) 2823 if self.quotes_engine: 2824 self.quotes_engine.stop() 2825 2826 def prev_quote(self, widget=None): 2827 if self.quotes_engine and self.options.quotes_enabled: 2828 self.quote = self.quotes_engine.prev_quote() 2829 self.update_indicator() 2830 self.refresh_texts() 2831 2832 def next_quote(self, widget=None, bypass_history=False): 2833 if self.quotes_engine and self.options.quotes_enabled: 2834 self.quote = self.quotes_engine.next_quote(bypass_history) 2835 self.update_indicator() 2836 self.refresh_texts() 2837 2838 def quote_copy_to_clipboard(self, widget=None): 2839 if self.quote: 2840 text = self.quote["quote"] + " - " + self.quote["author"] 2841 clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) 2842 clipboard.set_text(text, -1) 2843 clipboard.store() 2844 2845 def reload_quote_favorites_contents(self): 2846 self.quote_favorites_contents = "" 2847 try: 2848 if os.path.isfile(self.options.quotes_favorites_file): 2849 with open(self.options.quotes_favorites_file, encoding="utf8") as f: 2850 self.quote_favorites_contents = f.read() 2851 except Exception: 2852 logger.exception( 2853 lambda: "Could not load favorite quotes file %s" 2854 % self.options.quotes_favorites_file 2855 ) 2856 self.quote_favorites_contents = "" 2857 2858 def current_quote_to_text(self): 2859 return ( 2860 self.quote["quote"] 2861 + ("\n-- " + self.quote["author"] if self.quote["author"] else "") 2862 + "\n%\n" 2863 if self.quote 2864 else "" 2865 ) 2866 2867 def quote_save_to_favorites(self, widget=None): 2868 if self.quote: 2869 try: 2870 self.reload_quote_favorites_contents() 2871 if self.quote_favorites_contents.find(self.current_quote_to_text()) == -1: 2872 with open(self.options.quotes_favorites_file, "a") as f: 2873 text = self.current_quote_to_text() 2874 f.write(text) 2875 self.reload_quote_favorites_contents() 2876 self.update_indicator() 2877 self.show_notification( 2878 "Saved", "Saved to %s" % self.options.quotes_favorites_file 2879 ) 2880 else: 2881 self.show_notification(_("Already in Favorites")) 2882 except Exception: 2883 logger.exception(lambda: "Could not save quote to favorites") 2884 self.show_notification( 2885 "Oops, something went wrong when trying to save the quote to the favorites file" 2886 ) 2887 2888 def quote_view_favorites(self, widget=None): 2889 if os.path.isfile(self.options.quotes_favorites_file): 2890 subprocess.Popen(["xdg-open", self.options.quotes_favorites_file]) 2891 2892 def on_quotes_pause_resume(self, widget=None, change_enabled=None): 2893 if change_enabled is None: 2894 self.options.quotes_change_enabled = not self.options.quotes_change_enabled 2895 else: 2896 self.options.quotes_change_enabled = change_enabled 2897 2898 if self.preferences_dialog: 2899 self.preferences_dialog.ui.quotes_change_enabled.set_active( 2900 self.options.quotes_change_enabled 2901 ) 2902 2903 self.options.write() 2904 self.update_indicator(auto_changed=False) 2905 if self.quotes_engine: 2906 self.quotes_engine.on_options_updated(False) 2907 2908 def view_quote(self, widget=None): 2909 if self.quote and self.quote.get("link", None): 2910 webbrowser.open_new_tab(self.quote["link"]) 2911 2912 def google_quote_text(self, widget=None): 2913 if self.quote and self.quote["quote"]: 2914 url = "https://google.com/search?q=" + urllib.parse.quote_plus( 2915 self.quote["quote"].encode("utf8") 2916 ) 2917 webbrowser.open_new_tab(url) 2918 2919 def google_quote_author(self, widget=None): 2920 if self.quote and self.quote["author"]: 2921 url = "https://google.com/search?q=" + urllib.parse.quote_plus( 2922 self.quote["author"].encode("utf8") 2923 ) 2924 webbrowser.open_new_tab(url) 2925 2926 def google_image_search(self, widget=None): 2927 if self.image_url: 2928 url = ( 2929 "https://www.google.com/searchbyimage?safe=off&image_url=" 2930 + urllib.parse.quote_plus(self.image_url.encode("utf8")) 2931 ) 2932 webbrowser.open_new_tab(url) 2933 2934 def toggle_no_effects(self, no_effects): 2935 self.no_effects_on = self.current if no_effects else None 2936 self.refresh_wallpaper() 2937 2938 def create_desktop_entry(self): 2939 """ 2940 Creates a profile-specific desktop entry in ~/.local/share/applications 2941 This ensures Variety's icon context menu is for the correct profile, and also that 2942 application's windows will be correctly grouped by profile. 2943 """ 2944 if is_default_profile(): 2945 return 2946 2947 try: 2948 desktop_file_folder = os.path.expanduser("~/.local/share/applications") 2949 profile_name = get_profile_short_name() 2950 desktop_file_path = os.path.join(desktop_file_folder, get_desktop_file_name()) 2951 2952 should_notify = not os.path.exists(desktop_file_path) 2953 2954 Util.makedirs(desktop_file_folder) 2955 Util.copy_with_replace( 2956 varietyconfig.get_data_file("variety-profile.desktop.template"), 2957 desktop_file_path, 2958 { 2959 "{PROFILE_PATH}": get_profile_path(expanded=True), 2960 "{PROFILE_NAME}": (profile_name), 2961 "{VARIETY_PATH}": Util.get_exec_path(), 2962 "{WM_CLASS}": get_profile_wm_class(), 2963 }, 2964 ) 2965 2966 if should_notify: 2967 self.show_notification( 2968 _("Variety: New desktop entry"), 2969 _( 2970 "We created a new desktop entry in ~/.local/share/applications " 2971 'to run Variety with profile "{}". Find it in the application launcher.' 2972 ).format(profile_name), 2973 ) 2974 except Exception: 2975 logger.exception(lambda: "Could not create desktop entry for a run with --profile") 2976 2977 def create_autostart_entry(self): 2978 try: 2979 autostart_file_path = get_autostart_file_path() 2980 Util.makedirs(os.path.dirname(autostart_file_path)) 2981 should_notify = not os.path.exists(autostart_file_path) 2982 2983 Util.copy_with_replace( 2984 varietyconfig.get_data_file("variety-autostart.desktop.template"), 2985 autostart_file_path, 2986 { 2987 "{PROFILE_PATH}": get_profile_path(expanded=True), 2988 "{VARIETY_PATH}": Util.get_exec_path(), 2989 "{WM_CLASS}": get_profile_wm_class(), 2990 }, 2991 ) 2992 2993 if should_notify: 2994 self.show_notification( 2995 _("Variety: Created autostart desktop entry"), 2996 _( 2997 "We created a new desktop entry in ~/.config/autostart. " 2998 "Variety should start automatically on next login." 2999 ), 3000 ) 3001 except Exception: 3002 logger.exception(lambda: "Error while creating autostart desktop entry") 3003 self.show_notification( 3004 _("Could not create autostart entry"), 3005 _( 3006 "An error occurred while creating the autostart desktop entry\n" 3007 "Please run from a terminal with the -v flag and try again." 3008 ), 3009 ) 3010 3011 def on_start_slideshow(self, widget=None): 3012 def _go(): 3013 try: 3014 if self.options.slideshow_mode.lower() != "window": 3015 subprocess.call(["killall", "-9", "variety-slideshow"]) 3016 3017 args = ["variety-slideshow"] 3018 args += ["--seconds", str(self.options.slideshow_seconds)] 3019 args += ["--fade", str(self.options.slideshow_fade)] 3020 args += ["--zoom", str(self.options.slideshow_zoom)] 3021 args += ["--pan", str(self.options.slideshow_pan)] 3022 if "," in self.options.slideshow_sort_order.lower(): 3023 sort = self.options.slideshow_sort_order.lower().split(",")[0] 3024 order = self.options.slideshow_sort_order.lower().split(",")[1] 3025 else: 3026 sort = self.options.slideshow_sort_order.lower() 3027 order = "asc" 3028 args += ["--sort", sort] 3029 args += ["--order", order] 3030 args += ["--mode", self.options.slideshow_mode.lower()] 3031 3032 images = [] 3033 folders = [] 3034 if self.options.slideshow_sources_enabled: 3035 for source in self.options.sources: 3036 if source[0]: 3037 type = source[1] 3038 if type not in Options.get_all_supported_source_types(): 3039 continue 3040 location = source[2] 3041 3042 if type == Options.SourceType.IMAGE: 3043 images.append(location) 3044 else: 3045 folder = self.get_folder_of_source(source) 3046 if folder: 3047 folders.append(folder) 3048 3049 if self.options.slideshow_favorites_enabled: 3050 folders.append(self.options.favorites_folder) 3051 if self.options.slideshow_downloads_enabled: 3052 folders.append(self.options.download_folder) 3053 if self.options.slideshow_custom_enabled and os.path.isdir( 3054 self.options.slideshow_custom_folder 3055 ): 3056 folders.append(self.options.slideshow_custom_folder) 3057 3058 if not images and not folders: 3059 folders.append(self.options.favorites_folder) 3060 3061 if not list( 3062 Util.list_files( 3063 files=images, 3064 folders=folders, 3065 filter_func=Util.is_image, 3066 max_files=1, 3067 randomize=False, 3068 ) 3069 ): 3070 self.show_notification( 3071 _("No images"), _("There are no images in the slideshow folders") 3072 ) 3073 return 3074 3075 args += images 3076 args += folders 3077 3078 if self.options.slideshow_monitor.lower() != "all": 3079 try: 3080 args += ["--monitor", str(int(self.options.slideshow_monitor))] 3081 except: 3082 pass 3083 subprocess.Popen(args) 3084 else: 3085 screen = Gdk.Screen.get_default() 3086 for i in range(0, screen.get_n_monitors()): 3087 new_args = list(args) 3088 new_args += ["--monitor", str(i + 1)] 3089 subprocess.Popen(new_args) 3090 except: 3091 logger.exception("Could not start slideshow:") 3092 3093 threading.Thread(target=_go).start() 3094