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&currency_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