diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 37d6d86..0000000 --- a/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# This file copied from -# https://python-packaging.readthedocs.io/en/latest/minimal.html?highlight=gitignore - -# Compiled python modules. -*.pyc - -# Setuptools distribution folder. -/dist/ - -# Python egg metadata, regenerated from source files by setuptools. -/*.egg-info diff --git a/docs/mswin_install.rst b/docs/mswin_install.rst index 813543e..286d121 100644 --- a/docs/mswin_install.rst +++ b/docs/mswin_install.rst @@ -1,32 +1,38 @@ Tartube installation problems on MS Windows =========================================== -Some users on MS Windows report that they can't run Tartube at all. +Some users on MS Windows report that they can't run Tartube at all. There are three possible fixes. -This page describes what you can do, if you are one of them. +Fix #1 +~~~~~~ -The problem -~~~~~~~~~~~ +Wait for v1.2 of Tartube, which should fix the problem. -A full installation of Tartube and all of its dependencies uses over 2GB of your hard drive. The download is over 600MB. +Fix #2 +~~~~~~ -This is obviously too much, so I've removed everything that is not necessary. As a result, the installer is a 90MB download. +Find the **tartube_mswin.sh** file, and modify it. -The installer works for most people, but some users are reporting that they can't run Tartube at all. +If you used the MS Windows installer, it should be installed in -Obviously, something is missing from the installer. I can't reproduce the problem on any computer, so I don't know what is missing. +**C:\\Users\\YOURNAME\\AppData\\Local\\Tartube\\msys64\\home\\user\\tartube** -The solution -~~~~~~~~~~~~ +(You may have to `make hidden folders visible <https://support.microsoft.com/en-us/help/14201/windows-show-hidden-files>`__ to see the parent folder.) -A workaround is to perform a manual installation. This takes about 10-30 minutes, depending on your internet speed. +Open the file in a text editor, and replace this line -If the manual installation works, you can try to diagnose the original problem. +**cd tartube** -As soon as someone discovers what is missing from the installer, I can add it, and everyone will be happy again. +with this line -MS Windows manual installation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**cd /home/user/tartube/tartube** + +Save the file. You may now be able to run Tartube from the Windows Start Menu, or from the desktop shortcut. + +Fix #3 +~~~~~~ + +Perform a manual installation. This takes about 10-30 minutes, depending on your internet speed. - This section assumes you have a 64-bit computer - Download and install MSYS2 from `msys2.org <https://msys2.org>`__. You need the file that looks something like **msys2-x86_64-yyyymmdd.exe** @@ -61,26 +67,3 @@ MS Windows manual installation **python3 tartube** -Diagnosing the problem ----------------------- - -If the manual installation works, and if you have the time and the patience, you can work out what the missing dependency is. When you find it, please `tell me <https://github.com/axcore/tartube/issues>`__. - -- Download the normal installer from `Sourceforge <https://tartube.sourceforge.io/>`__ -- Run the installer. Tell it to install Tartube in **C:\\tartube** - -.. image:: screenshots/diagnose1.png - :alt: Set the installation folder - -- You now have two installations - a working one in **C:\\msys64\\**, and a broken one in **C:\\tartube\\msys64\\** - -.. image:: screenshots/diagnose2.png - :alt: Picture of both folders - -- Now, you can start moving files and folders from the working folder into the broken folder, **one at a time** -- For example, you could move the file **C:\\msys64\\usr\\bin\\awk** to **C:\\tartube\\msys64\\usr\\bin\\awk** -- For example, you could move the folder **C:\\msys64\\usr\\etc** to **C:\\tartube\\msys64\\usr\\bin\\etc** -- Every time you copy something, try to run Tartube (from the Start menu, or from the desktop shortcut) -- When Tartube runs for the first time, `tell me which file/folder you moved <https://github.com/axcore/tartube/issues>`__. I don't need to know everything - just tell me the **last** thing you moved. - - diff --git a/docs/mswin_install_old.rst b/docs/mswin_install_old.rst new file mode 100644 index 0000000..813543e --- /dev/null +++ b/docs/mswin_install_old.rst @@ -0,0 +1,86 @@ +Tartube installation problems on MS Windows +=========================================== + +Some users on MS Windows report that they can't run Tartube at all. + +This page describes what you can do, if you are one of them. + +The problem +~~~~~~~~~~~ + +A full installation of Tartube and all of its dependencies uses over 2GB of your hard drive. The download is over 600MB. + +This is obviously too much, so I've removed everything that is not necessary. As a result, the installer is a 90MB download. + +The installer works for most people, but some users are reporting that they can't run Tartube at all. + +Obviously, something is missing from the installer. I can't reproduce the problem on any computer, so I don't know what is missing. + +The solution +~~~~~~~~~~~~ + +A workaround is to perform a manual installation. This takes about 10-30 minutes, depending on your internet speed. + +If the manual installation works, you can try to diagnose the original problem. + +As soon as someone discovers what is missing from the installer, I can add it, and everyone will be happy again. + +MS Windows manual installation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- This section assumes you have a 64-bit computer +- Download and install MSYS2 from `msys2.org <https://msys2.org>`__. You need the file that looks something like **msys2-x86_64-yyyymmdd.exe** +- MSYS2 wants to install in **C:\\msys64**, so do that +- Open the MINGW64 terminal, which is **C:\\msys64\\mingw64.exe** +- In the MINGW64 terminal, type: + + **pacman -Syu** + +- If the terminal wants to shut down, close it, and then restart it +- Now type the following commands, one by one: + + **pacman -Su** + + **pacman -S mingw-w64-x86_64-python3** + + **pacman -S mingw-w64-x86_64-python3-pip** + + **pacman -S mingw-w64-x86_64-python3-gobject** + + **pacman -S mingw-w64-x86_64-python3-requests** + + **pacman -S mingw-w64-x86_64-gtk3** + + **pacman -S mingw-w64-x86_64-gsettings-desktop-schemas** + +- Download the `Tartube source code <https://sourceforge.net/projects/tartube/files/v0.7.0/tartube_v0.7.0.tar.gz/download>`__ from Sourceforge +- Extract it into the folder **C:\\msys64\\home\\YOURNAME**, creating a folder called **C:\\msys64\\home\\YOURNAME\\tartube** +- Now, to run Tartube, type these commands in the MINGW64 terminal: + + **cd tartube** + + **python3 tartube** + +Diagnosing the problem +---------------------- + +If the manual installation works, and if you have the time and the patience, you can work out what the missing dependency is. When you find it, please `tell me <https://github.com/axcore/tartube/issues>`__. + +- Download the normal installer from `Sourceforge <https://tartube.sourceforge.io/>`__ +- Run the installer. Tell it to install Tartube in **C:\\tartube** + +.. image:: screenshots/diagnose1.png + :alt: Set the installation folder + +- You now have two installations - a working one in **C:\\msys64\\**, and a broken one in **C:\\tartube\\msys64\\** + +.. image:: screenshots/diagnose2.png + :alt: Picture of both folders + +- Now, you can start moving files and folders from the working folder into the broken folder, **one at a time** +- For example, you could move the file **C:\\msys64\\usr\\bin\\awk** to **C:\\tartube\\msys64\\usr\\bin\\awk** +- For example, you could move the folder **C:\\msys64\\usr\\etc** to **C:\\tartube\\msys64\\usr\\bin\\etc** +- Every time you copy something, try to run Tartube (from the Start menu, or from the desktop shortcut) +- When Tartube runs for the first time, `tell me which file/folder you moved <https://github.com/axcore/tartube/issues>`__. I don't need to know everything - just tell me the **last** thing you moved. + + diff --git a/icons/small/archived.png b/icons/small/archived.png new file mode 100644 index 0000000..fd69e8f Binary files /dev/null and b/icons/small/archived.png differ diff --git a/icons/small/folder_black.png b/icons/small/folder_black.png new file mode 100644 index 0000000..cb1d414 Binary files /dev/null and b/icons/small/folder_black.png differ diff --git a/icons/small/folder_blue.png b/icons/small/folder_blue.png new file mode 100644 index 0000000..696561a Binary files /dev/null and b/icons/small/folder_blue.png differ diff --git a/icons/small/folder_green.png b/icons/small/folder_green.png new file mode 100644 index 0000000..68c3221 Binary files /dev/null and b/icons/small/folder_green.png differ diff --git a/icons/small/folder_red.png b/icons/small/folder_red.png new file mode 100644 index 0000000..83cef08 Binary files /dev/null and b/icons/small/folder_red.png differ diff --git a/icons/small/ok.png b/icons/small/ok.png deleted file mode 100644 index c277e6b..0000000 Binary files a/icons/small/ok.png and /dev/null differ diff --git a/setup.py b/setup.py index 2cfa38a..51efbf6 100755 --- a/setup.py +++ b/setup.py @@ -34,8 +34,10 @@ import sys # For the Debian distribution, use an environment variable. When specified, -# the default executable 'tartube' is replaced by the 'tartube_debian' -# executiable, in which youtube-dl updates are disabled +# the 'tartube_debian' file is the executable, rather than the 'tartube' +# file +# When the 'tartube_debian' file is the executable, youtube-dl updates are +# disabled, and Tartube's config file is stored at $XDG_CONFIG_HOME # The package maintainer should use # TARTUBE_NO_UPDATES=1 python3 setup.py build env_var_name = 'TARTUBE_NO_UPDATES' @@ -60,7 +62,7 @@ if env_var_value is not None: # Setup setuptools.setup( name='tartube', - version='1.1.015', + version='1.1.050', description='GUI front-end for youtube-dl', # long_description=long_description, long_description="""Tartube is a GUI front-end for youtube-dl, partly based diff --git a/tartube/config.py b/tartube/config.py index 1754b4a..71b03e5 100644 --- a/tartube/config.py +++ b/tartube/config.py @@ -575,7 +575,7 @@ class GenericEditWin(GenericConfigWin): Returns: - The image created + The Gtk.Frame containing the image """ @@ -588,7 +588,7 @@ class GenericEditWin(GenericConfigWin): self.app_obj.file_manager_obj.load_to_pixbuf(image_path), ) - return image + return frame def add_label(self, grid, text, x, y, wid, hei): @@ -1069,6 +1069,226 @@ class GenericEditWin(GenericConfigWin): # (Inherited by VideoEditWin, ChannelPlaylistEditWin and FolderEditWin) + def add_container_properties(self, grid): + + """Called by VideoEditWin.setup_tabs(), + ChannelPlaylistEditWin.setup_tabs() and FolderEditWin.setup_tabs(). + + Adds widgets common to those edit windows. + + Args: + + grid (Gtk.Grid): The grid on which widgets are arranged in their + tab + + """ + + entry = self.add_entry(grid, + None, + 0, 1, 1, 1, + ) + entry.set_text('#' + str(self.edit_obj.dbid)) + entry.set_editable(False) + entry.set_hexpand(False) + entry.set_width_chars(8) + + main_win_obj = self.app_obj.main_win_obj + parent_obj = self.edit_obj.parent_obj + if isinstance(self.edit_obj, media.Channel): + icon_path = main_win_obj.icon_dict['channel_small'] + elif isinstance(self.edit_obj, media.Playlist): + icon_path = main_win_obj.icon_dict['playlist_small'] + else: + icon_path = main_win_obj.icon_dict['folder_small'] + + frame = self.add_image(grid, + icon_path, + 1, 1, 1, 1, + ) + # (The frame looks cramped without this. The icon itself is 16x16) + frame.set_size_request( + 16 + (self.spacing_size * 2), + -1, + ) + + entry2 = self.add_entry(grid, + 'name', + 2, 1, 1, 1, + ) + entry2.set_editable(False) + + label = self.add_label(grid, + 'Listed as', + 0, 2, 1, 1, + ) + label.set_hexpand(False) + + entry3 = self.add_entry(grid, + 'nickname', + 2, 2, 1, 1, + ) + entry3.set_editable(False) + + label2 = self.add_label(grid, + 'Contained in', + 0, 3, 1, 1, + ) + label2.set_hexpand(False) + + if parent_obj: + icon_path2 = main_win_obj.icon_dict['folder_small'] + else: + icon_path2 = main_win_obj.icon_dict['folder_black_small'] + + frame2 = self.add_image(grid, + icon_path2, + 1, 3, 1, 1, + ) + frame2.set_size_request( + 16 + (self.spacing_size * 2), + -1, + ) + + entry4 = self.add_entry(grid, + None, + 2, 3, 1, 1, + ) + entry4.set_editable(False) + if parent_obj: + entry4.set_text(parent_obj.name) + + + def add_source_properties(self, grid): + + """Called by VideoEditWin.setup_tabs() and + ChannelPlaylistEditWin.setup_tabs(). + + Adds widgets common to those edit windows. + + Args: + + grid (Gtk.Grid): The grid on which widgets are arranged in their + tab + + """ + + label2 = self.add_label(grid, + utils.upper_case_first(self.media_type) + ' URL', + 0, 4, 1, 1, + ) + label2.set_hexpand(False) + + entry5 = self.add_entry(grid, + 'source', + 1, 4, 2, 1, + ) + entry5.set_editable(False) + + + def add_destination_properties(self, grid): + + """Called by ChannelPlaylistEditWin.setup_tabs() and + FolderEditWin.setup_tabs(). + + Adds widgets common to those edit windows. + + Args: + + grid (Gtk.Grid): The grid on which widgets are arranged in their + tab + + """ + + # To avoid messing up the neat format of the rows above, add another + # grid, and put the next set of widgets inside it + grid2 = Gtk.Grid() + grid.attach(grid2, 0, 5, 3, 1) + grid2.set_vexpand(False) + grid2.set_column_spacing(self.spacing_size) + grid2.set_row_spacing(self.spacing_size) + + label3 = self.add_label(grid2, + 'Videos downloaded to', + 0, 0, 1, 1, + ) + label3.set_hexpand(False) + + main_win_obj = self.app_obj.main_win_obj + dest_obj = self.app_obj.media_reg_dict[self.edit_obj.master_dbid] + if isinstance(dest_obj, media.Channel): + icon_path3 = main_win_obj.icon_dict['channel_small'] + elif isinstance(dest_obj, media.Playlist): + icon_path3 = main_win_obj.icon_dict['playlist_small'] + else: + icon_path3 = main_win_obj.icon_dict['folder_small'] + + frame3 = self.add_image(grid2, + icon_path3, + 1, 0, 1, 1, + ) + frame3.set_size_request( + 16 + (self.spacing_size * 2), + -1, + ) + + entry6 = self.add_entry(grid2, + None, + 2, 0, 1, 1, + ) + entry6.set_editable(False) + entry6.set_text(dest_obj.name) + + label5 = self.add_label(grid2, + 'Location on filesystem', + 0, 1, 1, 1, + ) + label5.set_hexpand(False) + + entry7 = self.add_entry(grid2, + None, + 1, 1, 2, 1, + ) + entry7.set_editable(False) + entry7.set_text(self.edit_obj.get_dir(self.app_obj)) + + + def setup_download_options_tab(self): + + """Called by self.setup_tabs(). + + Sets up the 'General' tab. + """ + + tab, grid = self.add_notebook_tab('_Download options') + + # Download options + self.add_label(grid, + '<u>Download options</u>', + 0, 0, 2, 1, + ) + + self.apply_options_button = Gtk.Button('Apply download options') + grid.attach(self.apply_options_button, 0, 1, 1, 1) + self.apply_options_button.connect( + 'clicked', + self.on_button_apply_clicked, + ) + + self.edit_button = Gtk.Button('Edit download options') + grid.attach(self.edit_button, 1, 1, 1, 1) + self.edit_button.connect('clicked', self.on_button_edit_clicked) + + self.remove_button = Gtk.Button('Remove download options') + grid.attach(self.remove_button, 1, 2, 1, 1) + self.remove_button.connect('clicked', self.on_button_remove_clicked) + + if self.edit_obj.options_obj: + self.apply_options_button.set_sensitive(False) + else: + self.edit_button.set_sensitive(False) + self.remove_button.set_sensitive(False) + + def on_button_apply_clicked(self, button): """Called from callback in self.setup_general_tab(). @@ -1090,9 +1310,9 @@ class GenericEditWin(GenericConfigWin): # Apply download options to the media data object self.app_obj.apply_download_options(self.edit_obj) # (De)sensitise buttons appropriately - self.apply_button.set_sensitive(False) - self.edit_button.set_sensitive(True) - self.remove_button.set_sensitive(True) + self.apply_options_button.set_sensitive(False) + self.edit_options_button.set_sensitive(True) + self.remove_options_button.set_sensitive(True) def on_button_edit_clicked(self, button): @@ -1142,9 +1362,9 @@ class GenericEditWin(GenericConfigWin): # Remove download options from the media data object self.app_obj.remove_download_options(self.edit_obj) # (De)sensitise buttons appropriately - self.apply_button.set_sensitive(True) - self.edit_button.set_sensitive(False) - self.remove_button.set_sensitive(False) + self.apply_options_button.set_sensitive(True) + self.edit_options_button.set_sensitive(False) + self.remove_options_button.set_sensitive(False) class GenericPrefWin(GenericConfigWin): @@ -1929,30 +2149,75 @@ class OptionsEditWin(GenericEditWin): self.file_tab_sensitise_widgets(False) # Filesystem options - - # (empty label for spacing) - self.add_label(grid, - '', - 0, 7, grid_width, 1, - ) - self.add_label(grid, '<u>Filesystem options</u>', - 0, 8, grid_width, 1, + 0, 7, grid_width, 1, ) self.add_checkbutton(grid, 'Restrict filenames to using ASCII characters', 'restrict_filenames', - 0, 9, grid_width, 1, + 0, 8, grid_width, 1, ) self.add_checkbutton(grid, 'Set the file modification time from the server', 'nomtime', - 0, 10, grid_width, 1, + 0, 9, grid_width, 1, ) + # Filesystem overrides + self.add_label(grid, + '<u>Filesystem overrides</u>', + 0, 10, grid_width, 1, + ) + + checkbutton = self.add_checkbutton(grid, + 'Download all videos into this folder', + None, + 0, 11, 2, 1, + ) + # Signal connect below + + # (Currently, only two fixed folders are elligible for this mode, so + # we'll just add them individually) + store5 = Gtk.ListStore(GdkPixbuf.Pixbuf, str) + pixbuf = self.app_obj.main_win_obj.pixbuf_dict['folder_green_small'] + store5.append( [pixbuf, self.app_obj.fixed_misc_folder.name] ) + pixbuf = self.app_obj.main_win_obj.pixbuf_dict['folder_blue_small'] + store5.append( [pixbuf, self.app_obj.fixed_temp_folder.name] ) + + combo5 = Gtk.ComboBox.new_with_model(store5) + grid.attach(combo5, 2, 11, (grid_width - 2), 1) + renderer_pixbuf5 = Gtk.CellRendererPixbuf() + combo5.pack_start(renderer_pixbuf5, False) + combo5.add_attribute(renderer_pixbuf5, 'pixbuf', 0) + renderer_text5 = Gtk.CellRendererText() + combo5.pack_start(renderer_text5, True) + combo5.add_attribute(renderer_text5, 'text', 1) + combo5.set_entry_text_column(1) + # Signal connect below + + current_override = self.edit_obj.options_dict['use_fixed_folder'] + if current_override is None: + checkbutton.set_active(False) + combo5.set_sensitive(False) + combo5.set_active(0) + else: + checkbutton.set_active(True) + combo5.set_sensitive(True) + if current_override == self.app_obj.fixed_temp_folder.name: + combo5.set_active(1) + else: + # The value should be either None, 'Unsorted Videos' or + # 'Temporary Videos'. In case the value is anything else, + # use 'Unsorted Videos' + combo5.set_active(0) + + # Signal connects from above + checkbutton.connect('toggled', self.on_fixed_folder_toggled, combo5) + combo5.connect('changed', self.on_fixed_folder_changed) + def setup_formats_tab(self): @@ -2546,6 +2811,48 @@ class OptionsEditWin(GenericEditWin): # Callback class methods + def on_fixed_folder_toggled(self, checkbutton, combo): + + """Called by callback in self.setup_files_tab(). + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + combo (Gtk.ComboBox): Another widget to be modified by this + function + + """ + + if not checkbutton.get_active(): + self.edit_dict['use_fixed_folder'] = None + combo.set_sensitive(False) + + else: + + tree_iter = combo.get_active_iter() + model = combo.get_model() + pixbuf, name = model[tree_iter][:2] + self.edit_dict['use_fixed_folder'] = name + combo.set_sensitive(True) + + + def on_fixed_folder_changed(self, combo): + + """Called by callback in self.setup_files_tab(). + + Args: + + combo (Gtk.ComboBox): The widget clicked + + """ + + tree_iter = combo.get_active_iter() + model = combo.get_model() + pixbuf, name = model[tree_iter][:2] + self.edit_dict['use_fixed_folder'] = name + + def on_file_tab_button_clicked(self, button, entry, combo): """Called by callback in self.setup_files_tab(). @@ -2994,9 +3301,9 @@ class VideoEditWin(GenericEditWin): self.ok_button = None # Gtk.Button self.cancel_button = None # Gtk.Button # (Non-standard widgets) - self.apply_button = None # Gtk.Button - self.edit_button = None # Gtk.Button - self.remove_button = None # Gtk.Button + self.apply_options_button = None # Gtk.Button + self.edit_options_button = None # Gtk.Button + self.remove_options_button = None # Gtk.Button # IV list - other @@ -3020,6 +3327,9 @@ class VideoEditWin(GenericEditWin): # closed, the dictionary will still be empty) self.edit_dict = {} + # String identifying the media type + self.media_type = 'video' + # Code # ---- @@ -3070,6 +3380,7 @@ class VideoEditWin(GenericEditWin): """ self.setup_general_tab() + self.setup_download_options_tab() self.setup_descrip_tab() self.setup_errors_warnings_tab() @@ -3088,68 +3399,10 @@ class VideoEditWin(GenericEditWin): 0, 0, 2, 1, ) - entry = self.add_entry(grid, - None, - 0, 1, 1, 1, - ) - entry.set_text('#' + str(self.edit_obj.dbid)) - entry.set_editable(False) - entry.set_hexpand(False) - entry.set_width_chars(8) - - entry2 = self.add_entry(grid, - 'name', - 1, 1, 1, 1, - ) - entry2.set_editable(False) - - label = self.add_label(grid, - 'Listed as', - 0, 2, 1, 1, - ) - label.set_hexpand(False) - - entry3 = self.add_entry(grid, - 'nickname', - 1, 2, 1, 1, - ) - entry3.set_editable(False) - - parent_obj = self.edit_obj.parent_obj - if isinstance(parent_obj, media.Channel): - icon_path \ - = self.app_obj.main_win_obj.icon_dict['channel_none_large'] - elif isinstance(parent_obj, media.Playlist): - icon_path \ - = self.app_obj.main_win_obj.icon_dict['playlist_none_large'] - else: - icon_path \ - = self.app_obj.main_win_obj.icon_dict['folder_none_large'] - - self.add_image(grid, - icon_path, - 0, 3, 1, 1, - ) - - entry4 = self.add_entry(grid, - None, - 1, 3, 1, 1, - ) - entry4.set_text(parent_obj.name) - entry4.set_editable(False) - - label2 = self.add_label(grid, - 'URL', - 0, 4, 1, 1, - ) - label2.set_hexpand(False) - - entry5 = self.add_entry(grid, - 'source', - 1, 4, 1, 1, - ) - entry5.set_editable(False) - + # The first sets of widgets are shared by multiple edit windows + self.add_container_properties(grid) + self.add_source_properties(grid) + label3 = self.add_label(grid, 'File', 0, 5, 1, 1, @@ -3158,7 +3411,7 @@ class VideoEditWin(GenericEditWin): entry6 = self.add_entry(grid, None, - 1, 5, 1, 1, + 1, 5, 2, 1, ) entry6.set_editable(False) if self.edit_obj.file_dir: @@ -3173,27 +3426,27 @@ class VideoEditWin(GenericEditWin): # To avoid messing up the neat format of the rows above, add another # grid, and put the next set of widgets inside it - grid2 = Gtk.Grid() - grid.attach(grid2, 0, 6, 2, 1) - grid2.set_vexpand(False) - grid2.set_border_width(self.spacing_size) - grid2.set_column_spacing(self.spacing_size) - grid2.set_row_spacing(self.spacing_size) + grid3 = Gtk.Grid() + grid.attach(grid3, 0, 6, 3, 1) + grid3.set_vexpand(False) + grid3.set_border_width(self.spacing_size) + grid3.set_column_spacing(self.spacing_size) + grid3.set_row_spacing(self.spacing_size) - checkbutton = self.add_checkbutton(grid2, + checkbutton = self.add_checkbutton(grid3, 'Always simulate download of this video', 'dl_sim_flag', 0, 0, 1, 1, ) checkbutton.set_sensitive(False) - label4 = self.add_label(grid2, + label4 = self.add_label(grid3, 'Duration', 1, 0, 1, 1, ) label4.set_hexpand(False) - entry7 = self.add_entry(grid2, + entry7 = self.add_entry(grid3, None, 2, 0, 1, 1, ) @@ -3203,20 +3456,20 @@ class VideoEditWin(GenericEditWin): utils.convert_seconds_to_string(self.edit_obj.duration), ) - checkbutton2 = self.add_checkbutton(grid2, + checkbutton2 = self.add_checkbutton(grid3, 'Video is marked as unwatched', 'new_flag', 0, 1, 1, 1, ) checkbutton2.set_sensitive(False) - label5 = self.add_label(grid2, + label5 = self.add_label(grid3, 'File size', 1, 1, 1, 1, ) label5.set_hexpand(False) - entry8 = self.add_entry(grid2, + entry8 = self.add_entry(grid3, None, 2, 1, 1, 1, ) @@ -3224,20 +3477,20 @@ class VideoEditWin(GenericEditWin): if self.edit_obj.file_size is not None: entry8.set_text(self.edit_obj.get_file_size_string()) - checkbutton3 = self.add_checkbutton(grid2, + checkbutton3 = self.add_checkbutton(grid3, 'Video is marked as favourite', 'fav_flag', 0, 2, 1, 1, ) checkbutton3.set_sensitive(False) - label6 = self.add_label(grid2, + label6 = self.add_label(grid3, 'Upload time', 1, 2, 1, 1, ) label6.set_hexpand(False) - entry9 = self.add_entry(grid2, + entry9 = self.add_entry(grid3, None, 2, 2, 1, 1, ) @@ -3245,20 +3498,20 @@ class VideoEditWin(GenericEditWin): if self.edit_obj.upload_time is not None: entry9.set_text(self.edit_obj.get_upload_time_string()) - checkbutton4 = self.add_checkbutton(grid2, + checkbutton4 = self.add_checkbutton(grid3, 'Video has been downloaded', 'dl_flag', 0, 3, 1, 1, ) checkbutton4.set_sensitive(False) - label7 = self.add_label(grid2, + label7 = self.add_label(grid3, 'Receive time', 1, 3, 1, 1, ) label7.set_hexpand(False) - entry10 = self.add_entry(grid2, + entry10 = self.add_entry(grid3, None, 2, 3, 1, 1, ) @@ -3266,28 +3519,8 @@ class VideoEditWin(GenericEditWin): if self.edit_obj.receive_time is not None: entry10.set_text(self.edit_obj.get_receive_time_string()) - # To avoid messing up the formatting again, put the next buttons inside - # an hbox - hbox = Gtk.HBox() - grid.attach(hbox, 0, 7, 2, 1) - self.apply_button = Gtk.Button('Apply download options') - hbox.pack_start(self.apply_button, True, True, self.spacing_size) - self.apply_button.connect('clicked', self.on_button_apply_clicked) - - self.edit_button = Gtk.Button('Edit download options') - hbox.pack_start(self.edit_button, True, True, self.spacing_size) - self.edit_button.connect('clicked', self.on_button_edit_clicked) - - self.remove_button = Gtk.Button('Remove download options') - hbox.pack_start(self.remove_button, True, True, self.spacing_size) - self.remove_button.connect('clicked', self.on_button_remove_clicked) - - if self.edit_obj.options_obj: - self.apply_button.set_sensitive(False) - else: - self.edit_button.set_sensitive(False) - self.remove_button.set_sensitive(False) +# def setup_download_options_tab(): # Inherited from GenericConfigWin def setup_descrip_tab(self): @@ -3417,9 +3650,9 @@ class ChannelPlaylistEditWin(GenericEditWin): self.ok_button = None # Gtk.Button self.cancel_button = None # Gtk.Button # (Non-standard widgets) - self.apply_button = None # Gtk.Button - self.edit_button = None # Gtk.Button - self.remove_button = None # Gtk.Button + self.apply_options_button = None # Gtk.Button + self.edit_options_button = None # Gtk.Button + self.remove_options_button = None # Gtk.Button # IV list - other @@ -3496,6 +3729,7 @@ class ChannelPlaylistEditWin(GenericEditWin): """ self.setup_general_tab() + self.setup_download_options_tab() self.setup_errors_warnings_tab() @@ -3510,176 +3744,95 @@ class ChannelPlaylistEditWin(GenericEditWin): self.add_label(grid, '<u>General properties</u>', - 0, 0, 2, 1, + 0, 0, 3, 1, ) - entry = self.add_entry(grid, - None, - 0, 1, 1, 1, - ) - entry.set_text('#' + str(self.edit_obj.dbid)) - entry.set_editable(False) - entry.set_hexpand(False) - entry.set_width_chars(8) - - entry2 = self.add_entry(grid, - 'name', - 1, 1, 1, 1, - ) - entry2.set_editable(False) - - label = self.add_label(grid, - 'Listed as', - 0, 2, 1, 1, - ) - label.set_hexpand(False) - - entry3 = self.add_entry(grid, - 'nickname', - 1, 2, 1, 1, - ) - entry3.set_editable(False) - - main_win_obj = self.app_obj.main_win_obj - parent_obj = self.edit_obj.parent_obj - if parent_obj: - icon_path = main_win_obj.icon_dict['folder_none_large'] - else: - icon_path = main_win_obj.icon_dict['folder_no_parent_none_large'] - - self.add_image(grid, - icon_path, - 0, 3, 1, 1, - ) - - entry4 = self.add_entry(grid, - None, - 1, 3, 1, 1, - ) - entry4.set_editable(False) - if parent_obj: - entry4.set_text(parent_obj.name) - - label2 = self.add_label(grid, - 'URL', - 0, 4, 1, 1, - ) - label2.set_hexpand(False) - - entry5 = self.add_entry(grid, - 'source', - 1, 4, 1, 1, - ) - entry5.set_editable(False) - - label3 = self.add_label(grid, - 'Location', - 0, 5, 1, 1, - ) - label3.set_hexpand(False) - - entry6 = self.add_entry(grid, - None, - 1, 5, 1, 1, - ) - entry6.set_editable(False) - entry6.set_text(self.edit_obj.get_dir(self.app_obj)) + # The first sets of widgets are shared by multiple edit windows + self.add_container_properties(grid) + self.add_source_properties(grid) + self.add_destination_properties(grid) # To avoid messing up the neat format of the rows above, add another # grid, and put the next set of widgets inside it - grid2 = Gtk.Grid() - grid.attach(grid2, 0, 6, 2, 1) - grid2.set_vexpand(False) - grid2.set_border_width(self.spacing_size) - grid2.set_column_spacing(self.spacing_size) - grid2.set_row_spacing(self.spacing_size) + grid3 = Gtk.Grid() + grid.attach(grid3, 0, 6, 3, 1) + grid3.set_vexpand(False) + grid3.set_column_spacing(self.spacing_size) + grid3.set_row_spacing(self.spacing_size) - checkbutton = self.add_checkbutton(grid2, + checkbutton = self.add_checkbutton(grid3, 'Always simulate download of videos in this ' + self.media_type, 'dl_sim_flag', 0, 0, 1, 1, ) checkbutton.set_sensitive(False) - checkbutton2 = self.add_checkbutton(grid2, - 'This ' + self.media_type + ' is marked as a favourite', - 'fav_flag', + checkbutton2 = self.add_checkbutton(grid3, + 'Disable checking/downloading for this ' + self.media_type, + 'dl_disable_flag', 0, 1, 1, 1, ) checkbutton2.set_sensitive(False) - self.add_label(grid2, + checkbutton3 = self.add_checkbutton(grid3, + 'This ' + self.media_type + ' is marked as a favourite', + 'fav_flag', + 0, 2, 1, 1, + ) + checkbutton3.set_sensitive(False) + + self.add_label(grid3, 'Total videos', 1, 0, 1, 1, ) - entry7 = self.add_entry(grid2, + entry8 = self.add_entry(grid3, 'vid_count', 2, 0, 1, 1, ) - entry7.set_editable(False) - entry7.set_width_chars(8) - entry7.set_hexpand(False) - - self.add_label(grid2, - 'New videos', - 1, 1, 1, 1, - ) - entry8 = self.add_entry(grid2, - 'new_count', - 2, 1, 1, 1, - ) entry8.set_editable(False) entry8.set_width_chars(8) entry8.set_hexpand(False) - self.add_label(grid2, - 'Favourite videos', - 1, 2, 1, 1, + self.add_label(grid3, + 'New videos', + 1, 1, 1, 1, ) - entry9 = self.add_entry(grid2, - 'fav_count', - 2, 2, 1, 1, + entry9 = self.add_entry(grid3, + 'new_count', + 2, 1, 1, 1, ) entry9.set_editable(False) entry9.set_width_chars(8) entry9.set_hexpand(False) - self.add_label(grid2, - 'Downloaded videos', - 1, 3, 1, 1, + self.add_label(grid3, + 'Favourite videos', + 1, 2, 1, 1, ) - entry10 = self.add_entry(grid2, - 'dl_count', - 2, 3, 1, 1, + entry10 = self.add_entry(grid3, + 'fav_count', + 2, 2, 1, 1, ) entry10.set_editable(False) entry10.set_width_chars(8) entry10.set_hexpand(False) - # To avoid messing up the formatting again, but the next buttons inside - # an hbox - hbox = Gtk.HBox() - grid.attach(hbox, 0, 7, 2, 1) - - self.apply_button = Gtk.Button('Apply download options') - hbox.pack_start(self.apply_button, True, True, self.spacing_size) - self.apply_button.connect('clicked', self.on_button_apply_clicked) - - self.edit_button = Gtk.Button('Edit download options') - hbox.pack_start(self.edit_button, True, True, self.spacing_size) - self.edit_button.connect('clicked', self.on_button_edit_clicked) - - self.remove_button = Gtk.Button('Remove download options') - hbox.pack_start(self.remove_button, True, True, self.spacing_size) - self.remove_button.connect('clicked', self.on_button_remove_clicked) - - if self.edit_obj.options_obj: - self.apply_button.set_sensitive(False) - else: - self.edit_button.set_sensitive(False) - self.remove_button.set_sensitive(False) + self.add_label(grid3, + 'Downloaded videos', + 1, 3, 1, 1, + ) + entry11 = self.add_entry(grid3, + 'dl_count', + 2, 3, 1, 1, + ) + entry11.set_editable(False) + entry11.set_width_chars(8) + entry11.set_hexpand(False) +# def setup_download_options_tab(): # Inherited from GenericConfigWin + + def setup_errors_warnings_tab(self): """Called by self.setup_tabs(). @@ -3780,9 +3933,9 @@ class FolderEditWin(GenericEditWin): self.ok_button = None # Gtk.Button self.cancel_button = None # Gtk.Button # (Non-standard widgets) - self.apply_button = None # Gtk.Button - self.edit_button = None # Gtk.Button - self.remove_button = None # Gtk.Button + self.apply_options_button = None # Gtk.Button + self.edit_options_button = None # Gtk.Button + self.remove_options_button = None # Gtk.Button # IV list - other @@ -3806,6 +3959,9 @@ class FolderEditWin(GenericEditWin): # closed, the dictionary will still be empty) self.edit_dict = {} + # String identifying the media type + self.media_type = 'folder' + # Code # ---- @@ -3856,6 +4012,7 @@ class FolderEditWin(GenericEditWin): """ self.setup_general_tab() + self.setup_download_options_tab() def setup_general_tab(self): @@ -3872,148 +4029,79 @@ class FolderEditWin(GenericEditWin): 0, 0, 2, 1, ) - entry = self.add_entry(grid, - None, - 0, 1, 1, 1, - ) - entry.set_text('#' + str(self.edit_obj.dbid)) - entry.set_editable(False) - entry.set_hexpand(False) - entry.set_width_chars(8) - - entry2 = self.add_entry(grid, - 'name', - 1, 1, 1, 1, - ) - entry2.set_editable(False) - - label = self.add_label(grid, - 'Listed as', - 0, 2, 1, 1, - ) - label.set_hexpand(False) - - entry3 = self.add_entry(grid, - 'nickname', - 1, 2, 1, 1, - ) - entry3.set_editable(False) - - main_win_obj = self.app_obj.main_win_obj - parent_obj = self.edit_obj.parent_obj - if parent_obj: - icon_path = main_win_obj.icon_dict['folder_none_large'] - else: - icon_path = main_win_obj.icon_dict['folder_no_parent_none_large'] - - self.add_image(grid, - icon_path, - 0, 3, 1, 1, - ) - - entry4 = self.add_entry(grid, - None, - 1, 3, 1, 1, - ) - entry4.set_editable(False) - if parent_obj: - entry4.set_text(parent_obj.name) - - label2 = self.add_label(grid, - 'Location', - 0, 4, 1, 1, - ) - label2.set_hexpand(False) - - entry5 = self.add_entry(grid, - None, - 1, 4, 1, 1, - ) - entry5.set_editable(False) - entry5.set_text(self.edit_obj.get_dir(self.app_obj)) + # The first sets of widgets are shared by multiple edit windows + self.add_container_properties(grid) + self.add_destination_properties(grid) # To avoid messing up the neat format of the rows above, add another # grid, and put the next set of widgets inside it - grid2 = Gtk.Grid() - grid.attach(grid2, 0, 5, 2, 1) - grid2.set_vexpand(False) - grid2.set_border_width(self.spacing_size) - grid2.set_column_spacing(self.spacing_size) - grid2.set_row_spacing(self.spacing_size) + grid3 = Gtk.Grid() + grid.attach(grid3, 0, 6, 3, 1) + grid3.set_vexpand(False) + grid3.set_border_width(self.spacing_size) + grid3.set_column_spacing(self.spacing_size) + grid3.set_row_spacing(self.spacing_size) - checkbutton = self.add_checkbutton(grid2, + checkbutton = self.add_checkbutton(grid3, 'Always simulate download of videos', 'dl_sim_flag', 0, 0, 1, 1, ) checkbutton.set_sensitive(False) - checkbutton2 = self.add_checkbutton(grid2, - 'This folder is marked as a favourite', - 'fav_flag', + checkbutton2 = self.add_checkbutton(grid3, + 'Disable checking/downloading for this folder', + 'dl_disable_flag', 0, 1, 1, 1, ) checkbutton2.set_sensitive(False) - checkbutton3 = self.add_checkbutton(grid2, - 'This folder is hidden', - 'hidden_flag', + checkbutton3 = self.add_checkbutton(grid3, + 'This folder is marked as a favourite', + 'fav_flag', 0, 2, 1, 1, ) checkbutton3.set_sensitive(False) - checkbutton4 = self.add_checkbutton(grid2, + checkbutton4 = self.add_checkbutton(grid3, + 'This folder is hidden', + 'hidden_flag', + 0, 3, 1, 1, + ) + checkbutton4.set_sensitive(False) + + checkbutton5 = self.add_checkbutton(grid3, 'This folder can\'t be deleted by the user', 'fixed_flag', 1, 0, 1, 1, ) - checkbutton4.set_sensitive(False) + checkbutton5.set_sensitive(False) - checkbutton5 = self.add_checkbutton(grid2, + checkbutton6 = self.add_checkbutton(grid3, 'This is a system-controlled folder', 'priv_flag', 1, 1, 1, 1, ) - checkbutton5.set_sensitive(False) + checkbutton6.set_sensitive(False) - checkbutton6 = self.add_checkbutton(grid2, + checkbutton7 = self.add_checkbutton(grid3, 'Only videos can be added to this folder', 'restrict_flag', 1, 2, 1, 1, ) - checkbutton6.set_sensitive(False) + checkbutton7.set_sensitive(False) - checkbutton7 = self.add_checkbutton(grid2, + checkbutton8 = self.add_checkbutton(grid3, 'All contents deleted when ' \ + utils.upper_case_first(__main__. __packagename__) \ + ' shuts down', 'temp_flag', 1, 3, 1, 1, ) - checkbutton7.set_sensitive(False) + checkbutton8.set_sensitive(False) - # To avoid messing up the formatting again, but the next buttons inside - # an hbox - hbox = Gtk.HBox() - grid.attach(hbox, 0, 6, 2, 1) - self.apply_button = Gtk.Button('Apply download options') - hbox.pack_start(self.apply_button, True, True, self.spacing_size) - self.apply_button.connect('clicked', self.on_button_apply_clicked) - - self.edit_button = Gtk.Button('Edit download options') - hbox.pack_start(self.edit_button, True, True, self.spacing_size) - self.edit_button.connect('clicked', self.on_button_edit_clicked) - - self.remove_button = Gtk.Button('Remove download options') - hbox.pack_start(self.remove_button, True, True, self.spacing_size) - self.remove_button.connect('clicked', self.on_button_remove_clicked) - - if self.edit_obj.options_obj: - self.apply_button.set_sensitive(False) - else: - self.edit_button.set_sensitive(False) - self.remove_button.set_sensitive(False) +# def setup_download_options_tab(): # Inherited from GenericConfigWin # Callback class methods @@ -4153,9 +4241,7 @@ class SystemPrefWin(GenericPrefWin): # (This is a placeholder, to be replaced when we add translations) store = Gtk.ListStore(GdkPixbuf.Pixbuf, str) - pixbuf = self.app_obj.file_manager_obj.load_to_pixbuf( - os.path.abspath(os.path.join('icons', 'locale', 'flag_uk.png')), - ) + pixbuf = self.app_obj.main_win_obj.pixbuf_dict['flag_uk'] store.append( [pixbuf, 'English'] ) combo = Gtk.ComboBox.new_with_model(store) @@ -4516,6 +4602,47 @@ class SystemPrefWin(GenericPrefWin): self.on_match_spinbutton_changed, ) + # Video deletion preferences + self.add_label(grid, + '<u>Video deletion preferences</u>', + 0, 7, grid_width, 1, + ) + + checkbutton2 = self.add_checkbutton(grid, + 'Automatically delete downloaded videos after this many days', + self.app_obj.auto_delete_flag, + True, # Can be toggled by user + 0, 8, (grid_width - 1), 1, + ) + # Signal connect appears below + + spinbutton3 = self.add_spinbutton(grid, + 1, 999, 1, self.app_obj.auto_delete_days, + 2, 8, 1, 1, + ) + # Signal connect appears below + + checkbutton3 = self.add_checkbutton(grid, + '...but only delete videos which have been watched', + self.app_obj.auto_delete_watched_flag, + True, # Can be toggled by user + 0, 9, grid_width, 1, + ) + # Signal connect appears below + + # Signal connects from above + checkbutton2.connect( + 'toggled', + self.on_auto_delete_button_toggled, + spinbutton3, + checkbutton2, + ) + spinbutton3.connect( + 'value-changed', + self.on_auto_delete_spinbutton_changed, + ) + checkbutton3.connect('toggled', self.on_delete_watched_button_toggled) + def setup_operations_tab(self): @@ -4540,6 +4667,8 @@ class SystemPrefWin(GenericPrefWin): 0, 1, grid_width, 1, ) checkbutton.connect('toggled', self.on_auto_update_button_toggled) + if __main__.__debian_install_flag__: + checkbutton.set_sensitive(False) checkbutton2 = self.add_checkbutton(grid, 'Automatically save files at the end of a download/update/' \ @@ -4752,7 +4881,7 @@ class SystemPrefWin(GenericPrefWin): ) combo2.connect('changed', self.on_update_combo_changed) - if __main__.__disable_ytdl_update_flag__: + if __main__.__debian_install_flag__: combo.set_sensitive(False) combo2.set_sensitive(False) @@ -4866,6 +4995,53 @@ class SystemPrefWin(GenericPrefWin): # Callback class methods + def on_auto_delete_button_toggled(self, checkbutton, spinbutton, + checkbutton2): + + """Called from callback in self.setup_videos_tab(). + + Enables/disables automatic deletion of downloaded videos. + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + spinbutton (Gtk.SpinButton): A widget to be (de)sensitised + + checkbutton2 (Gtk.CheckButton): Another widget to be + (de)sensitised + + """ + + if checkbutton.get_active() \ + and not self.app_obj.auto_delete_flag: + self.app_obj.set_auto_delete_flag(True) + spinbutton.set_sensitive(True) + checkbutton2.set_sensitive(True) + + elif not checkbutton.get_active() \ + and self.app_obj.auto_delete_flag: + self.app_obj.set_auto_delete_flag(False) + spinbutton.set_sensitive(False) + checkbutton2.set_sensitive(False) + + + def on_auto_delete_spinbutton_changed(self, spinbutton): + + """Called from callback in self.setup_videos_tab(). + + Sets the number of days after which downloaded videos should be + deleted. + + Args: + + spinbutton (Gtk.SpinButton): The widget clicked + + """ + + self.app_obj.set_auto_delete_days(spinbutton.get_value()) + + def on_auto_update_button_toggled(self, checkbutton): """Called from callback in self.setup_general_tab(). @@ -5135,6 +5311,27 @@ class SystemPrefWin(GenericPrefWin): ) + def on_delete_watched_button_toggled(self, checkbutton): + + """Called from callback in self.setup_videos_tab(). + + Enables/disables automatic deletion of videos, but only those that have + been watched. + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + """ + + if checkbutton.get_active() \ + and not self.app_obj.auto_delete_watched_flag: + self.app_obj.set_auto_delete_watched_flag(True) + elif not checkbutton.get_active() \ + and self.app_obj.auto_delete_watched_flag: + self.app_obj.set_auto_delete_watched_flag(False) + + def on_dialogue_button_toggled(self, checkbutton): """Called from callback in self.setup_general_tab(). diff --git a/tartube/downloads.py b/tartube/downloads.py index 1788972..f2dac7c 100755 --- a/tartube/downloads.py +++ b/tartube/downloads.py @@ -341,6 +341,53 @@ class DownloadManager(threading.Thread): break + def check_master_slave(self, media_data_obj): + + """Called by VideoDownloader.do_download(). + + When two channels/playlists/folders share a download destination, we + don't want to download both of them at the same time. + + This function is called when media_data_obj is about to be + downloaded. + + Every worker is checked, to see if it's downloading to the same + destination. If so, this function returns True, and + VideoDownloader.do_download() waits a few seconds, before trying + again. + + Otherwise, this function returns False, and + VideoDownloader.do_download() is free to start its download. + + Args: + + media_data_obj (media.Channel, media.Playlist, media.Folder): + The media data object that the calling function wants to + download + + Returns: + + True or False, as described above + + """ + + for worker_obj in self.worker_list: + + if not worker_obj.available_flag \ + and worker_obj.download_item_obj: + + other_obj = worker_obj.download_item_obj.media_data_obj + + if other_obj.dbid != media_data_obj.dbid \ + and ( + other_obj.dbid == media_data_obj.master_dbid \ + or other_obj.dbid in media_data_obj.slave_dbid_list + ): + return True + + return False + + def check_workers_all_finished(self): """Called by self.run(). @@ -737,7 +784,7 @@ class DownloadList(object): # Code # ---- - + # For each media data object to be downloaded, created a # downloads.DownloadItem object, and update the IVs above if not media_data_obj: @@ -763,7 +810,7 @@ class DownloadList(object): else: # Use the specified media data object. The True value tells - # self.create_item to download media_data_obj, even if it is a + # self.create_item() to download media_data_obj, even if it is a # video in a channel or a playlist (which otherwise would be # handled by downloading the channel/playlist) self.create_item(media_data_obj, True) @@ -810,8 +857,13 @@ class DownloadList(object): - media.Video objects in any restricted folder - media.Video objects in the fixed 'Unsorted Videos' folder which are already marked as downloaded + - media.Video objects which have an ancestor (e.g. a parent + media.Channel) for which checking/downloading is disabled + - media.Channel and media.Playlist objects for which checking/ + downloading are disabled, or which have an ancestor (e.g. a + parent media.folder) for which checking/downloading is disabled - media.Folder objects - + Adds the resulting downloads.DownloadItem object to this object's IVs. Args: @@ -855,6 +907,22 @@ class DownloadList(object): and not init_flag ): return + + # Don't create a download.DownloadItem object if the media data object + # has an ancestor for which checking/downloading is disabled + if isinstance(media_data_obj, media.Video): + dl_disable_flag = False + else: + dl_disable_flag = media_data_obj.dl_disable_flag + + parent_obj = media_data_obj.parent_obj + + while not dl_disable_flag and parent_obj is not None: + dl_disable_flag = parent_obj.dl_disable_flag + parent_obj = parent_obj.parent_obj + + if dl_disable_flag: + return # Don't create a download.DownloadItem object for a media.Folder, # obviously @@ -1102,11 +1170,26 @@ class VideoDownloader(object): # The time (in seconds) between iterations of the loop in # self.do_download() self.sleep_time = 0.1 + # The time (in seconds) to wait for an existing download, which shares + # a common download destination with this media data object, to + # finish downloading + self.long_sleep_time = 10 # Flag set to True if we are simulating downloads for this media data # object, or False if we actually downloading videos (set below) self.dl_sim_flag = None + # Flag set to True by a call from any function to self.stop_soon() + # After being set to True, this VideoDownloader should give up after + # the next call to self.confirm_new_video(), .confirm_old_video() + # .confirm_sim_video() + self.stop_soon_flag = False + # When self.stop_soon_flag is True, the next call to + # self.confirm_new_video(), .confirm_old_video() or + # .confirm_sim_video() sets this flag to True, informing + # self.do_download() that it can stop the child process + self.stop_now_flag = False + # youtube-dl is passed a URL, which might represent an individual # video, a channel or a playlist # Assume it's an individual video unless youtube-dl reports a @@ -1177,17 +1260,20 @@ class VideoDownloader(object): # download media_data_obj = self.download_item_obj.media_data_obj - # If the media data object is a video, channel or playlist, it can be - # marked as a simulated download only - # If it's a video inside a folder and the folder itself is marked as - # simulated downloads only, apply that to all videos in the folder - if self.download_manager_obj.force_sim_flag \ - or media_data_obj.dl_sim_flag \ - or ( - isinstance(media_data_obj, media.Video) \ - and isinstance(media_data_obj.parent_obj, media.Folder) \ - and media_data_obj.parent_obj.dl_sim_flag - ): + # All media data objects can be marked as simulate downloads only. The + # setting applies not just to the media data object, but all of its + # descendants + if self.download_manager_obj.force_sim_flag: + dl_sim_flag = True + else: + dl_sim_flag = media_data_obj.dl_sim_flag + parent_obj = media_data_obj.parent_obj + + while not dl_sim_flag and parent_obj is not None: + dl_sim_flag = parent_obj.dl_sim_flag + parent_obj = parent_obj.parent_obj + + if dl_sim_flag: self.dl_sim_flag = True self.video_num = 0 self.video_total = 0 @@ -1229,6 +1315,18 @@ class VideoDownloader(object): # time it was checked/downloaded self.download_item_obj.media_data_obj.reset_error_warning() + # If two channels/playlists/folders share a download destination, we + # don't want to download both of them at the same time + # If this media data obj shares a download destination with another + # downloads.DownloadWorker, wait until that download has finished + # before starting this one + if not isinstance(self.download_item_obj.media_data_obj, media.Video): + + while self.download_manager_obj.check_master_slave( + self.download_item_obj.media_data_obj, + ): + time.sleep(self.long_sleep_time) + # Prepare a system command... cmd_list = self.get_system_cmd() # ...and create a new child process using that command @@ -1307,6 +1405,12 @@ class VideoDownloader(object): + ' to fetch a video\'s JSON data', ) + # Stop this video downloader, if required to do so, having just + # finished checking/downloading a video + if self.stop_now_flag: + self.stop() + + # The child process has finished while not self.stderr_queue.empty(): @@ -1456,6 +1560,11 @@ class VideoDownloader(object): options_manager_obj.options_dict['keep_thumbnail'], ) + # This VideoDownloader can now stop, if required to do so after a video + # has been checked/downloaded + if self.stop_soon_flag: + self.stop_now_flag = True + def confirm_old_video(self, dir_path, filename, extension): @@ -1549,16 +1658,37 @@ class VideoDownloader(object): = self.download_worker_obj.options_manager_obj # Update the main window - GObject.timeout_add( - 0, - app_obj.announce_video_download, - self.download_item_obj, - video_obj, - options_manager_obj.options_dict['keep_description'], - options_manager_obj.options_dict['keep_info'], - options_manager_obj.options_dict['keep_thumbnail'], - ) + if media_data_obj.master_dbid != media_data_obj.dbid: + # The container is storing its videos in another + # container's sub-directory, which (probably) explains + # why we couldn't find a match. Don't add anything to the + # Results List + GObject.timeout_add( + 0, + app_obj.announce_video_clone, + video_obj, + ) + + else: + + # Do add an entry to the Results List (as well as updating + # the Video Catalogue, as normal) + GObject.timeout_add( + 0, + app_obj.announce_video_download, + self.download_item_obj, + video_obj, + options_manager_obj.options_dict['keep_description'], + options_manager_obj.options_dict['keep_info'], + options_manager_obj.options_dict['keep_thumbnail'], + ) + + # This VideoDownloader can now stop, if required to do so after a video + # has been checked/downloaded + if self.stop_soon_flag: + self.stop_now_flag = True + def confirm_sim_video(self, json_dict): @@ -1851,6 +1981,11 @@ class VideoDownloader(object): if stop_flag: self.stop() + # This VideoDownloader can now stop, if required to do so after a video + # has been checked/downloaded + elif self.stop_soon_flag: + self.stop_now_flag = True + def create_child_process(self, cmd_list): @@ -2243,6 +2378,9 @@ class VideoDownloader(object): if DEBUG_FUNC_FLAG: print('dl 1997 get_system_cmd') + # Import things for convenience + app_obj = self.download_manager_obj.app_obj + media_data_obj = self.download_item_obj.media_data_obj options_list = self.download_worker_obj.options_list # Simulate the download, rather than actually downloading videos, if @@ -2250,13 +2388,30 @@ class VideoDownloader(object): if self.dl_sim_flag: options_list.append('--dump-json') + # If actually downloading videos, create an archive file so that, if + # the user deletes the videos, youtube-dl won't try to download them + # again + else: + + # (Create the archive file in the media data object's own + # sub-directory, not the alternative download destination, as + # this helps youtube-dl to work the way we want it) + if isinstance(media_data_obj, media.Video): + dl_path = media_data_obj.parent_obj.get_dir(app_obj) + else: + dl_path = media_data_obj.get_dir(app_obj) + + options_list.append('--download-archive') + options_list.append( + os.path.abspath(os.path.join(dl_path, 'ytdl-archive.txt')), + ) + # Show verbose output (youtube-dl debugging mode), if required - if self.download_manager_obj.app_obj.ytdl_write_verbose_flag: + if app_obj.ytdl_write_verbose_flag: options_list.append('--verbose') # Set the list - cmd_list = [self.download_manager_obj.app_obj.ytdl_path] \ - + options_list + [self.download_item_obj.media_data_obj.source] + cmd_list = [app_obj.ytdl_path] + options_list + [media_data_obj.source] return cmd_list @@ -2478,7 +2633,8 @@ class VideoDownloader(object): def stop(self): - """Called by downloads.DownloadWorker.close(). + """Called by downloads.DownloadWorker.close() and also by + mainwin.MainWin.on_progress_list_stop_now(). Terminates the child process and sets this object's return code to self.STOPPED. @@ -2505,6 +2661,21 @@ class VideoDownloader(object): self.set_return_code(self.STOPPED) + def stop_soon(self): + + """Can be called by anything. Currently called by + mainwin.MainWin.on_progress_list_stop_soon(). + + Sets the flag that causes this VideoDownloader to stop after the + current video. + """ + + if DEBUG_FUNC_FLAG: + print('dl 2224 stop_soon') + + self.stop_soon_flag = True + + class PipeReader(threading.Thread): """Called by downloads.VideoDownloader.__init__(). diff --git a/tartube/formats.py b/tartube/formats.py index 914fa31..596bf9e 100755 --- a/tartube/formats.py +++ b/tartube/formats.py @@ -457,15 +457,19 @@ SMALL_ICON_DICT = { 'playlist_small': 'playlist.png', 'folder_small': 'folder.png', - 'download_small': 'download.png', + 'archived_small': 'archived.png', 'check_small': 'check.png', + 'download_small': 'download.png', + 'error_small': 'error.png', + 'folder_black_small': 'folder_black.png', + 'folder_blue_small': 'folder_blue.png', + 'folder_green_small': 'folder_green.png', + 'folder_red_small': 'folder_red.png', 'have_file_small': 'have_file.png', 'no_file_small': 'no_file.png', - 'ok_small': 'ok.png', - 'error_small': 'error.png', - 'warning_small': 'warning.png', 'system_error_small': 'system_error.png', 'system_warning_small': 'system_warning.png', + 'warning_small': 'warning.png', } WIN_ICON_LIST = [ diff --git a/tartube/mainapp.py b/tartube/mainapp.py index 46f6fcd..b95dd0f 100755 --- a/tartube/mainapp.py +++ b/tartube/mainapp.py @@ -46,9 +46,13 @@ try: except: HAVE_MOVIEPY_FLAG = False -from xdg.BaseDirectory import xdg_config_home - +try: + from xdg.BaseDirectory import xdg_config_home + HAVE_XDG_FLAG = True +except: + HAVE_XDG_FLAG = False + # Import our modules import __main__ import config @@ -91,12 +95,6 @@ class TartubeApp(Gtk.Application): flags=Gio.ApplicationFlags.FLAGS_NONE, **kwargs) - # Debugging flags (can only be set by editing the source code) - # Force Tartube to use the pre-v1.1.014 location for the config file - self.debug_old_config_flag = False - # Delete the config file and the contents of Tartube's data directory - # on startup - self.debug_delete_data_flag = False # After installation, don't show the dialogue windows prompting the # user to choose Tartube's data directory; just use the default # location @@ -280,23 +278,23 @@ class TartubeApp(Gtk.Application): # Name of the Tartube config file self.config_file_name = 'settings.json' - # Path to the config file. Before v1.1.014, the Tartube config file was - # stored in self.script_parent_dir; it is now stored in - # $XDG_CONFIG_HOME/tartube + # The config file can be stored at one of two locations, depending on + # the value of __main__.__debian_install_flag__ self.config_file_path = os.path.abspath( - os.path.join( - xdg_config_home, - __main__.__packagename__, - self.config_file_name, - ), - ) - self.config_file_old_path = os.path.abspath( os.path.join(self.script_parent_dir, self.config_file_name), ) - if self.debug_old_config_flag: - self.config_file_path = self.config_file_old_path - + if not HAVE_XDG_FLAG: + self.config_file_xdg_path = None + else: + self.config_file_xdg_path = os.path.abspath( + os.path.join( + xdg_config_home, + __main__.__packagename__, + self.config_file_name, + ), + ) + # Name of the Tartube database file (storing media data objects). The # database file is always found in self.data_dir self.db_file_name = __main__.__packagename__ + '.db' @@ -506,12 +504,12 @@ class TartubeApp(Gtk.Application): self.operation_save_flag = True # Flag set to True if a dialogue window should be shown at the end of # each download/update/refresh operation - self.operation_dialogue_flag = True + self.operation_dialogue_flag = True # Flag set to True if self.update_video_from_filesystem() should get # the video duration, if not already known, using the moviepy.editor - # module (which may be slow) + # module (an optional dependency) self.use_module_moviepy_flag = True - + # Flag set to True if dialogue windows for adding videos, channels and # playlists should copy the contents of the system clipboard self.dialogue_copy_clipboard_flag = True @@ -583,6 +581,20 @@ class TartubeApp(Gtk.Application): # 1-999 self.match_ignore_chars = self.match_default_chars + # Automatic video deletion. Applies only to downloaded videos (not to + # checked videos) + # Flag set to True if videos should be deleted after a certain time + self.auto_delete_flag = False + # Flag set to True if videos are automatically deleted after a certain + # time, but only if they have been watched (media.Video.dl_flag is + # True, media.Video.new_flag is False; ignored if + # self.auto_delete_old_flag is False) + self.auto_delete_watched_flag = False + # Videos are automatically deleted after this many days (must be an + # integer, minimum value 1; ignored if self.auto_delete_old_flag is + # False) + self.auto_delete_days = 30 + # How much information to show in the Video Index. False to show # minimal video stats, True to show full video stats self.complex_index_flag = False @@ -663,9 +675,19 @@ class TartubeApp(Gtk.Application): export_db_menu_action.connect('activate', self.on_menu_export_db) self.add_action(export_db_menu_action) - import_db_menu_action = Gio.SimpleAction.new('import_db_menu', None) - import_db_menu_action.connect('activate', self.on_menu_import_db) - self.add_action(import_db_menu_action) + import_json_menu_action = Gio.SimpleAction.new( + 'import_json_menu', + None, + ) + import_json_menu_action.connect('activate', self.on_menu_import_json) + self.add_action(import_json_menu_action) + + import_text_menu_action = Gio.SimpleAction.new( + 'import_text_menu', + None, + ) + import_text_menu_action.connect('activate', self.on_menu_import_plain_text) + self.add_action(import_text_menu_action) switch_view_menu_action = Gio.SimpleAction.new( 'switch_view_menu', @@ -726,7 +748,11 @@ class TartubeApp(Gtk.Application): about_menu_action = Gio.SimpleAction.new('about_menu', None) about_menu_action.connect('activate', self.on_menu_about) self.add_action(about_menu_action) - + + go_website_menu_action = Gio.SimpleAction.new('go_website_menu', None) + go_website_menu_action.connect('activate', self.on_menu_go_website) + self.add_action(go_website_menu_action) + # Main toolbar actions # -------------------- @@ -999,23 +1025,11 @@ class TartubeApp(Gtk.Application): self.main_win_obj, ) - # Delete Tartube's config file and data directory, if the debugging - # flag is set - if self.debug_delete_data_flag: - if os.path.isfile(self.config_file_path): - os.remove(self.config_file_path) - - if os.path.isfile(self.config_file_old_path): - os.remove(self.config_file_old_path) - - if os.path.isdir(self.data_dir): - shutil.rmtree(self.data_dir) - # Give mainapp.TartubeApp IVs their initial values self.general_options_obj = options.OptionsManager() # Set youtube-dl IVs - if __main__.__disable_ytdl_update_flag__: + if __main__.__debian_install_flag__: self.ytdl_bin = 'youtube-dl' self.ytdl_path_default = os.path.abspath( os.path.join(os.sep, 'usr', 'bin', self.ytdl_bin), @@ -1103,43 +1117,70 @@ class TartubeApp(Gtk.Application): ] self.ytdl_update_current = 'Update using pip3 (recommended)' - # If the config file exists, load it (from either the default or the - # pre v1.1.014 location). If not, create it + # If the config file exists, load it. If not, create it new_mswin_flag = False - if os.path.isfile(self.config_file_path) \ - or os.path.isfile(self.config_file_old_path): + + if ( + not __main__.__debian_install_flag__ + and os.path.isfile(self.config_file_path) + ) or ( + __main__.__debian_install_flag__ + and self.config_file_xdg_path is not None + and os.path.isfile(self.config_file_xdg_path) + ): self.load_config() + elif self.debug_no_dialogue_flag: self.save_config() + else: - # (Need to show an extra prompt at the end of this function) + # New Tartube installation if os.name == 'nt': + + # On MS Windows, Cygwin creates a Tartube data directory at + # C:\msys64\home\USERNAME\tartube-data, which is not very + # convenient. Force the user to nominate the directory they + # want new_mswin_flag = True - - # On MS Windows, Cygwin creates a Tartube data directory at - # C:\msys64\home\USERNAME\tartube-data, which is not very - # convenient - # Even on Linux/*BSD, the user might want to choose their own data - # directory - # Therefore, on new installations, immediately ask the user to - # choose a location for the data directory - dialogue_win = mainwin.SetDirectoryDialogue( - self.main_win_obj, - self.data_dir, - ) - - response = dialogue_win.run() - - # Retrieve user choices from the dialogue window, before destroying - # it - custom_flag = False - if dialogue_win.button2.get_active(): custom_flag = True + + dialogue_win = self.dialogue_manager_obj.show_msg_dialogue( + 'Click OK to create a folder in which\n' \ + + utils.upper_case_first(__main__.__packagename__) \ + + ' can store its videos\n\nIf you have used ' \ + + utils.upper_case_first(__main__.__packagename__) \ + + ' before,\nyou can select an existing folder\ninstead of' + + ' creating a new one', + 'info', + 'ok', + self.main_win_obj, + ) - dialogue_win.destroy() + dialogue_win.set_modal(True) - if response == Gtk.ResponseType.OK and custom_flag: + else: + + # On Linux/BSD, offer the user a choice between using the + # default data directory specified by self.data_dir, or + # specifying their own data directory + dialogue_win = mainwin.SetDirectoryDialogue( + self.main_win_obj, + self.data_dir, + ) + + response = dialogue_win.run() + + # Retrieve user choices from the dialogue window, before + # destroying it + custom_flag = False + if response == Gtk.ResponseType.OK \ + and dialogue_win.button2.get_active(): + custom_flag = True + + dialogue_win.destroy() + + if custom_flag: if os.name == 'nt': folder = 'folder' @@ -1355,7 +1396,7 @@ class TartubeApp(Gtk.Application): currently assigned thus: 100-199: mainapp.py (in use: 101-128) - 200-299: mainwin.py (in use: 201-235) + 200-299: mainwin.py (in use: 201-236) 300-399: downloads.py (in use: 301-304) 400-499: config.py (in use: 401-404) @@ -1411,14 +1452,13 @@ class TartubeApp(Gtk.Application): if DEBUG_FUNC_FLAG: print('ap 1012 load_config') - # Before v1.1.014, the Tartube config file was stored in - # self.script_parent_dir; it is now stored in - # $XDG_CONFIG_HOME/tartube - config_file_path = self.config_file_path - if not os.path.isfile(config_file_path): - - # Try the old location - config_file_path = self.config_file_old_path + # The config file can be stored at one of two locations, depending on + # the value of __main__.__debian_install_flag__ + if __main__.__debian_install_flag__ \ + and self.config_file_xdg_path is not None: + config_file_path = self.config_file_xdg_path + else: + config_file_path = self.config_file_path # Sanity check if self.current_manager_obj \ @@ -1482,21 +1522,6 @@ class TartubeApp(Gtk.Application): + ' config file is invalid', ) - # If the config file was loaded from the pre-v1.1.014 location, move it - if config_file_path == self.config_file_old_path: - - destination_dir = os.path.abspath( - os.path.join( - xdg_config_home, - __main__.__packagename__, - ), - ) - - if not os.path.isdir(destination_dir): - os.makedirs(destination_dir) - - shutil.move(self.config_file_old_path, self.config_file_path) - # Set IVs to their new values if version >= 5024: # v0.5.024 self.toolbar_squeeze_flag = json_dict['toolbar_squeeze_flag'] @@ -1551,6 +1576,7 @@ class TartubeApp(Gtk.Application): = json_dict['operation_auto_update_flag'] self.operation_save_flag = json_dict['operation_save_flag'] self.operation_dialogue_flag = json_dict['operation_dialogue_flag'] + self.use_module_moviepy_flag = json_dict['use_module_moviepy_flag'] # # Removed v0.5.003 # self.use_module_validators_flag \ @@ -1590,6 +1616,12 @@ class TartubeApp(Gtk.Application): self.match_method = json_dict['match_method'] self.match_first_chars = json_dict['match_first_chars'] self.match_ignore_chars = json_dict['match_ignore_chars'] + + if version >= 1001029: # v1.1.029 + self.auto_delete_flag = json_dict['auto_delete_flag'] + self.auto_delete_watched_flag \ + = json_dict['auto_delete_watched_flag'] + self.auto_delete_days = json_dict['auto_delete_days'] self.complex_index_flag = json_dict['complex_index_flag'] if version >= 3019: # v0.3.019 @@ -1610,7 +1642,15 @@ class TartubeApp(Gtk.Application): if DEBUG_FUNC_FLAG: print('ap 1117 save_config') - + + # The config file can be stored at one of two locations, depending on + # the value of __main__.__debian_install_flag__ + if __main__.__debian_install_flag__ \ + and self.config_file_xdg_path is not None: + config_file_path = self.config_file_xdg_path + else: + config_file_path = self.config_file_path + # Sanity check if self.current_manager_obj or self.disable_load_save_flag: return @@ -1677,6 +1717,10 @@ class TartubeApp(Gtk.Application): 'match_method': self.match_method, 'match_first_chars': self.match_first_chars, 'match_ignore_chars': self.match_ignore_chars, + + 'auto_delete_flag': self.auto_delete_flag, + 'auto_delete_watched_flag': self.auto_delete_watched_flag, + 'auto_delete_days': self.auto_delete_days, 'complex_index_flag': self.complex_index_flag, 'catalogue_mode': self.catalogue_mode, @@ -1685,7 +1729,7 @@ class TartubeApp(Gtk.Application): # Try to save the file try: - with open(self.config_file_path, 'w') as outfile: + with open(config_file_path, 'w') as outfile: json.dump(json_dict, outfile, indent=4) except: @@ -1788,11 +1832,14 @@ class TartubeApp(Gtk.Application): self.fixed_misc_folder = load_dict['fixed_misc_folder'] self.fixed_temp_folder = load_dict['fixed_temp_folder'] + # Update the loaded data for this version of Tartube + self.update_db(version) + # Empty any temporary folders self.delete_temp_folders() - # Update the loaded data for this version of Tartube - self.update_db(version) + # Auto-delete old downloaded videos + self.auto_delete_old_videos() # If the debugging flag is set, hide all fixed (system) folders if self.debug_hide_folders_flag: @@ -2016,6 +2063,44 @@ class TartubeApp(Gtk.Application): media_data_obj.nickname = json_dict['title'] + if version < 1001031: # v1.1.031 + + # This version adds the ability to disable checking/downloading for + # media data objects + for dbid in self.media_name_dict.values(): + media_data_obj = self.media_reg_dict[dbid] + media_data_obj.dl_disable_flag = False + + if version < 1001032: # v1.1.032 + + # This version adds video archiving. Archived videos cannot be + # auto-deleted + for media_data_obj in self.media_reg_dict.values(): + if isinstance(media_data_obj, media.Video): + media_data_obj.archive_flag = False + + if version < 1001037: # v1.1.037 + + # This version adds alternative destination directories for a + # channel's/playlist's/folder's videos, thumbnails (etc) + for dbid in self.media_name_dict.values(): + media_data_obj = self.media_reg_dict[dbid] + media_data_obj.master_dbid = media_data_obj.dbid + media_data_obj.slave_dbid_list = [] + + + if version < 1001045: # v1.1.045 + + # This version adds a new option to options.OptionsManager + options_obj_list = [self.general_options_obj] + for media_data_obj in self.media_reg_dict.values(): + if media_data_obj.options_obj is not None: + options_obj_list.append(media_data_obj.options_obj) + + for options_obj in options_obj_list: + options_obj.options_dict['use_fixed_folder'] = None + + def save_db(self): """Called by self.start(), .stop(), .switch_db(), @@ -2429,7 +2514,7 @@ class TartubeApp(Gtk.Application): and media_data_obj.temp_flag: # Delete all child objects - for child_obj in media_data_obj.child_list: + for child_obj in list(media_data_obj.child_list.copy()): if isinstance(child_obj, media.Video): self.delete_video(child_obj) else: @@ -2443,6 +2528,43 @@ class TartubeApp(Gtk.Application): os.makedirs(dir_path) + def auto_delete_old_videos(self): + + """Called by self.load_db(). + + After loading the Tartube database, auto-delete any old downloaded + videos (if auto-deletion is enabled) + """ + + if DEBUG_FUNC_FLAG: + print('ap 1571 auto_delete_old_videos') + + if not self.auto_delete_flag: + return + + # Calculate the system time before which any downloaded videos can be + # deleted + time_limit = int(time.time()) - (self.auto_delete_days * 24 * 60 * 60) + + # Import a list of media data objects (as self.media_reg_dict will be + # modified during this operation) + media_list = list(self.media_reg_dict.values()) + + # Auto-delete any videos as required + for media_data_obj in media_list: + + if isinstance(media_data_obj, media.Video) \ + and media_data_obj.dl_flag \ + and not media_data_obj.archive_flag \ + and media_data_obj.receive_time < time_limit \ + and ( + not self.auto_delete_watched_flag \ + or not media_data_obj.new_flag + ): + # Ddelete this video + self.delete_video(media_data_obj, True, True, True) + + def convert_version(self, version): """Can be called by anything, but mostly called by self.load_config() @@ -2717,7 +2839,7 @@ class TartubeApp(Gtk.Application): return - elif __main__.__disable_ytdl_update_flag__: + elif __main__.__debian_install_flag__: # Update operation is disabled in the Debian package. It should not # be possible to call this function, but we'll show an error # message anyway @@ -2982,7 +3104,25 @@ class TartubeApp(Gtk.Application): if video_obj is None: # Create a new media data object for the video - video_obj = self.add_video(media_data_obj, None, no_sort_flag) + override_name = download_item_obj.options_manager_obj.options_dict['use_fixed_folder'] + if override_name is not None \ + and override_name in self.media_name_dict: + + other_dbid = self.media_name_dict[override_name] + other_parent_obj = self.media_reg_dict[other_dbid] + + video_obj = self.add_video( + other_parent_obj, + None, + no_sort_flag, + ) + + else: + video_obj = self.add_video( + media_data_obj, + None, + no_sort_flag, + ) # Since we have them to hand, set the video's file path IVs # immediately @@ -3155,6 +3295,52 @@ class TartubeApp(Gtk.Application): self.mark_video_downloaded(video_obj, True) + def announce_video_clone(self, video_obj): + + """Called by downloads.VideoDownloader.confirm_old_video(). + + This is a modified version of self.update_video_when_file_found(), + called when a channel/playlist/folder is using an alternative + download destination for its videos (in which case, + self.update_video_when_file_found() can't be called). + + Args: + + video_obj (media.Video): The video which already exists on the + user's filesystem (in the alternative download destination) + + """ + + video_path = os.path.abspath( + os.path.join( + video_obj.file_dir, + video_obj.file_name + video_obj.file_ext, + ) + ) + + # Only set the .name IV if the video is currently unnamed + if video_obj.name == self.default_video_name: + video_obj.set_name(video_obj.file_name) + # (The video's title, stored in the .nickname IV, will be updated + # from the JSON data in a momemnt) + video_obj.set_nickname(video_obj.file_name) + + # Set the file size + video_obj.set_file_size(os.path.getsize(video_path)) + + # If the JSON file was downloaded, we can extract video statistics from + # it + self.update_video_from_json(video_obj) + + # For any of those statistics that haven't been set (because the JSON + # file was missing or didn't contain the right statistics), set them + # directly + self.update_video_from_filesystem(video_obj, video_path) + + # Mark the video as (fully) downloaded (and update everything else) + self.mark_video_downloaded(video_obj, True) + + def update_video_from_json(self, video_obj): """Called by self.update_video_when_file_found() and @@ -3811,12 +3997,12 @@ class TartubeApp(Gtk.Application): # (Delete media data objects) - def delete_video(self, video_obj, no_update_index_flag=False, - delete_files_flag=False): + def delete_video(self, video_obj, delete_files_flag=False, + no_update_index_flag=False, no_update_catalogue_flag=False): - """Called by self.delete_temp_folders(), .delete_container(), - mainwin.MainWin.video_catalogue_popup_menu() and a callback in - mainwin.MainWin.on_video_catalogue_delete_video(). + """Called by self.delete_temp_folders(), .delete_old_videos(), + .delete_container(), mainwin.MainWin.video_catalogue_popup_menu() and a + callback in mainwin.MainWin.on_video_catalogue_delete_video(). Deletes a video object from the media registry. @@ -3824,15 +4010,19 @@ class TartubeApp(Gtk.Application): video_obj (media.Video): The media.Video object to delete - no_update_index_flag (True or False): True when called by - self.delete_container(), in which case the Video Index is not - updated (because the calling function wants to do that) - delete_files_flag (True or False): True when called by mainwin.MainWin.on_video_catalogue_delete_video, in which case the video and its associated files are deleted from the filesystem + no_update_index_flag (True or False): True when called by + self.delete_old_videos() or self.delete_container(), in which + case the Video Index is not updated + + no_update_catalogue_flag (True or False): True when called by + self.delete_old_videos(), in which case the Video Catalogue is + not updated + """ if DEBUG_FUNC_FLAG: @@ -3901,7 +4091,9 @@ class TartubeApp(Gtk.Application): os.remove(json_path) # Remove the video from the catalogue, if present - self.main_win_obj.video_catalogue_delete_row(video_obj) + if not no_update_catalogue_flag: + self.main_win_obj.video_catalogue_delete_row(video_obj) + # Update rows in the Video Index, first checking that the parent # container object is currently drawn there (which it might not be, # if emptying temporary folders on startup) @@ -4094,7 +4286,7 @@ class TartubeApp(Gtk.Application): copy_list = media_data_obj.child_list.copy() for child_obj in copy_list: if isinstance(child_obj, media.Video): - self.delete_video(child_obj, True) + self.delete_video(child_obj, False, True) else: self.delete_container_complete(child_obj, False, True) @@ -4105,6 +4297,9 @@ class TartubeApp(Gtk.Application): if media_data_obj.parent_obj: media_data_obj.parent_obj.del_child(media_data_obj) + # Reset alternative download destinations + media_data_obj.set_master_dbid(self, media_data_obj.dbid) + # Remove the media data object from our IVs del self.media_reg_dict[media_data_obj.dbid] del self.media_name_dict[media_data_obj.name] @@ -4300,6 +4495,8 @@ class TartubeApp(Gtk.Application): # Update the video object's IVs video_obj.set_dl_flag(False) + # (A video that is not downloaded cannot be marked archived) + video_obj.set_archive_flag(False) # Update the parent container object video_obj.parent_obj.dec_dl_count() # Update private folders @@ -4515,13 +4712,99 @@ class TartubeApp(Gtk.Application): self.main_win_obj.video_index_delete_row(folder_obj) + def mark_container_archived(self, media_data_obj, flag, + only_child_videos_flag): + + """Called by mainwin.MainWin.on_video_index_mark_archived() and + .on_video_index_mark_not_archived(). + + Marks any descedant videos as archived. + + Args: + + media_data_obj (media.Channel, media.Playlist or media.Folder): + The container object to update + + flag (True or False): True to mark as archived, False to mark as + not archived + + only_child_videos_flag (True or False): Set to True if only child + video objects should be marked; False if the container object + and all its descendants should be marked + + """ + + if DEBUG_FUNC_FLAG: + print('ap 3483 mark_container_archived') + + if isinstance(media_data_obj, media.Video): + return self.system_error( + 121, + 'Mark container as archived request failed sanity check', + ) + + # Special arrangements for private folders + if media_data_obj == self.fixed_all_folder: + + # Check every video + for other_obj in list(self.media_reg_dict.values()): + + if isinstance(other_obj, media.Video) and other_obj.dl_flag: + other_obj.set_archive_flag(flag) + + elif media_data_obj == self.fixed_new_folder: + + # Check videos in this folder + for other_obj in self.fixed_new_folder.child_list: + + if isinstance(other_obj, media.Video) and other_obj.dl_flag \ + and other_obj.new_flag: + other_obj.set_archive_flag(flag) + + elif not flag and media_data_obj == self.fixed_fav_folder: + + # Check videos in this folder + for other_obj in self.fixed_fav_folder.child_list: + + if isinstance(other_obj, media.Video) and other_obj.dl_flag \ + and other_obj.fav_flag: + other_obj.set_archive_flag(flag) + + elif only_child_videos_flag: + + # Check videos in this channel/playlist/folder + for other_obj in media_data_obj.child_list: + + if isinstance(other_obj, media.Video): + other_obj.set_archive_flag(flag) + + else: + + # Check videos in this channel/playlist/folder, and in any + # descendant channels/playlists/folders + for other_obj in media_data_obj.compile_all_videos( [] ): + + if isinstance(other_obj, media.Video) and other_obj.dl_flag: + other_obj.set_archive_flag(flag) + + # In all cases, update the row on the Video Index + self.main_win_obj.video_index_update_row_icon(media_data_obj) + self.main_win_obj.video_index_update_row_text(media_data_obj) + # If this container is the one visible in the Video Catalogue, redraw + # the Video Catalogue + if self.main_win_obj.video_index_current == media_data_obj.name: + self.main_win_obj.video_catalogue_redraw_all( + self.main_win_obj.video_index_current, + ) + + def mark_container_favourite(self, media_data_obj, flag, only_child_videos_flag): """Called by mainwin.MainWin.on_video_index_mark_favourite() and .on_video_index_mark_not_favourite(). - Mark this channel, playlist or folder as favourite. Also mark any + Marks this channel, playlist or folder as favourite. Also marks any descendant videos as favourite (but not descendent channels, playlists or folders). @@ -5054,24 +5337,29 @@ class TartubeApp(Gtk.Application): ) - def import_into_db(self): + def import_into_db(self, json_flag): - """Called by self.on_menu_import_db() or by any other function. + """Called by self.on_menu_import_json() or by any other function. - Imports the contents of an export file generated by a call to - self.export_from_db(). - - (Only imports JSON files; doesn't import the plain text files generated - by that function.) + Imports the contents of a JSON export file or a plain text export file + generated by a call to self.export_from_db(). After prompting the user, creates new media.Video, media.Channel, media.Playlist and/or media.Folder objects. Checks for duplicates and handles them appropriately. - The export file contains a dictionary, 'db_dict', containing further + A JSON export file contains a dictionary, 'db_dict', containing further dictionaries, 'mini_dict', whose formats are described in the comments in self.export_from_db(). + A plain text export file contains lines in groups of three, in the + format described in the comments in self.export_from_db(). + + Args: + + json_flag (bool): True if a JSON export file should be imported, + False if a plain text export file should be imported + """ if DEBUG_FUNC_FLAG: @@ -5099,42 +5387,62 @@ class TartubeApp(Gtk.Application): return # Try to load the export file - try: - with open(file_path) as infile: - json_dict = json.load(infile) + if not json_flag: - except: + text = self.file_manager_obj.load_text(file_path) + if text is None: + return self.dialogue_manager_obj.show_msg_dialogue( + 'Failed to load the database export file', + 'error', + 'ok', + ) + + # Parse the text file, creating a db_dict in the form described in + # the comments in self.export_from_db() + db_dict = self.parse_text_import(text) + + else: + + json_dict = self.file_manager_obj.load_json(file_path) + if not json_dict: + return self.dialogue_manager_obj.show_msg_dialogue( + 'Failed to load the database export file', + 'error', + 'ok', + ) + + # Do some basic checks on the loaded data + # (At the moment, JSON export files are compatible with all + # versions of Tartube after v1.0.0; this may change in future) + if not json_dict \ + or not 'script_name' in json_dict \ + or not 'script_version' in json_dict \ + or not 'save_date' in json_dict \ + or not 'save_time' in json_dict \ + or not 'file_type' in json_dict \ + or json_dict['script_name'] != __main__.__packagename__ \ + or json_dict['file_type'] != 'db_export': + return self.dialogue_manager_obj.show_msg_dialogue( + 'The database export file is invalid', + 'error', + 'ok', + ) + + # Retrieve the database data itself. db_dict is in the form + # described in the comments in self.export_from_db() + db_dict = json_dict['db_dict'] + + if not db_dict: return self.dialogue_manager_obj.show_msg_dialogue( - 'Failed to load the database export file', + 'The database export file\nis invalid (or empty)', 'error', 'ok', ) - - # Do some basic checks on the loaded data - if not json_dict \ - or not 'script_name' in json_dict \ - or not 'script_version' in json_dict \ - or not 'save_date' in json_dict \ - or not 'save_time' in json_dict \ - or not 'file_type' in json_dict \ - or json_dict['script_name'] != __main__.__packagename__ \ - or json_dict['file_type'] != 'db_export': - return self.dialogue_manager_obj.show_msg_dialogue( - 'The database export file is invalid', - 'error', - 'ok', - ) - - # (At the moment, export files are compatible with all versions of - # Tartube after v1.0.0; this may change in future) - + # Prompt the user to allow them to select which videos/channels/ # playlists/folders to actually import, and how to deal with # duplicate channels/playlists/folders - dialogue_win = mainwin.ImportDialogue( - self.main_win_obj, - json_dict['db_dict'], - ) + dialogue_win = mainwin.ImportDialogue(self.main_win_obj, db_dict) response = dialogue_win.run() # Retrieve user choices from the dialogue window, before destroying the @@ -5156,7 +5464,7 @@ class TartubeApp(Gtk.Application): # any duplicates (video_count, channel_count, playlist_count, folder_count) \ = self.process_import( - json_dict['db_dict'], # The imported 'db_dict' + db_dict, # The imported data flat_db_dict, # The flattened version of that dictionary None, # No parent 'mini_dict' yet import_videos_flag, @@ -5170,7 +5478,7 @@ class TartubeApp(Gtk.Application): if not video_count and not channel_count and not playlist_count \ and not folder_count: self.dialogue_manager_obj.show_msg_dialogue( - 'Nothing was imported from the database export file', + 'Nothing was imported from\nthe database export file', 'error', 'ok', ) @@ -5193,6 +5501,104 @@ class TartubeApp(Gtk.Application): self.dialogue_manager_obj.show_msg_dialogue(msg, 'info', 'ok') + def parse_text_import(self, text): + + """Called by self.import_into_db(). + + Given the contents of a plain text database export, which has been + loaded into memory, convert the contents into the db_dict format + described in the comments in self.export_from_db(), as if a JSON + database export had been loaded. + + The text file contains lines, in groups of three, in the following + format: + + @type + <name> + <url> + + ...where '@type' is one of '@video', '@channel' or '@playlist' (the + folder structure is never preserved in a plain text export). + + A video belongs to the channel/playlist above it. + + Args: + + text (str): The contents of the loaded plain text file + + Returns: + + db_dict (dict): The converted data in the form described in the + comments in self.export_from_db() + + """ + + db_dict = {} + dbid = 0 + last_container_mini_dict = None + + # Split text into separate lines + line_list = text.split('\n') + + # Remove all empty lines (including those containing only whitespace) + mod_list = [] + for line in line_list: + if re.match('\S', line): + mod_list.append(line) + + # Extract each group of three lines, and check they are valid + # If a group of three is invalid (or if we reach the end of the file + # in the middle of a group of 3), ignore that group and any + # subsequent groups, and just use the data already extracted + while len(mod_list) > 2: + + media_type = mod_list[0] + name = mod_list[1] + source = mod_list[2] + + mod_list = mod_list[3:] + + if media_type is None \ + or ( + media_type != '@video' and media_type != '@channel' \ + and media_type != '@playlist' + ) \ + or name is None or name == '' \ + or source is None or not utils.check_url(source): + break + + # A valid group of three; add an entry to db_dict using a fake dbid + dbid += 1 + + mini_dict = { + 'type': None, + 'dbid': dbid, + 'name': name, + 'nickname': name, + 'source': source, + 'db_dict': {}, + } + + if media_type == '@video': + mini_dict['type'] = 'video' + # A video belongs to the previous channel or playlist (if any) + if last_container_mini_dict is not None: + last_container_mini_dict['db_dict'][dbid] = mini_dict + + elif media_type == '@channel': + mini_dict['type'] = 'channel' + last_container_mini_dict = mini_dict + + else: + mini_dict['type'] = 'playlist' + last_container_mini_dict = mini_dict + + db_dict[dbid] = mini_dict + + # Procedure complete + return db_dict + + def process_import(self, db_dict, flat_db_dict, parent_obj, import_videos_flag, merge_duplicates_flag, video_count, channel_count, playlist_count, folder_count): @@ -5265,6 +5671,8 @@ class TartubeApp(Gtk.Application): # self.media_reg_dict) for dbid in db_dict.keys(): + media_data_obj = None + # Each 'mini_dict' contains details for a single video/channel/ # playlist/folder mini_dict = db_dict[dbid] @@ -5317,6 +5725,10 @@ class TartubeApp(Gtk.Application): mini_dict['name'], ) + mini_dict['nickname'] = self.rename_imported_container( + mini_dict['nickname'], + ) + else: # Use the existing channel/playlist/folder of the same @@ -5324,48 +5736,43 @@ class TartubeApp(Gtk.Application): old_dbid = self.media_name_dict[mini_dict['name']] media_data_obj = self.media_reg_dict[old_dbid] - else: - - # Import the channel/playlist/folder - media_data_obj = None - - if mini_dict['type'] == 'channel': - media_data_obj = self.add_channel( - mini_dict['name'], - parent_obj, - mini_dict['source'], - ) - - if media_data_obj: - channel_count += 1 - - elif mini_dict['type'] == 'playlist': - media_data_obj = self.add_playlist( - mini_dict['name'], - parent_obj, - mini_dict['source'], - ) - - if media_data_obj: - playlist_count += 1 - - elif mini_dict['type'] == 'folder': - media_data_obj = self.add_folder( - mini_dict['name'], - parent_obj, - ) - - if media_data_obj: - folder_count += 1 + # Import the channel/playlist/folder + if mini_dict['type'] == 'channel': + media_data_obj = self.add_channel( + mini_dict['name'], + parent_obj, + mini_dict['source'], + ) if media_data_obj: - media_data_obj.set_nickname(mini_dict['nickname']) + channel_count += 1 + + elif mini_dict['type'] == 'playlist': + media_data_obj = self.add_playlist( + mini_dict['name'], + parent_obj, + mini_dict['source'], + ) + + if media_data_obj: + playlist_count += 1 + + elif mini_dict['type'] == 'folder': + media_data_obj = self.add_folder( + mini_dict['name'], + parent_obj, + ) + + if media_data_obj: + folder_count += 1 # If the channel/playlist/folder was successfully imported, - # update the Video Index, then deal with any children by - # calling this function recursively + # set its nickname, update the Video Index, then deal with + # any children by calling this function recursively if media_data_obj is not None: + media_data_obj.set_nickname(mini_dict['nickname']) + self.main_win_obj.video_index_add_row(media_data_obj) if mini_dict['db_dict']: @@ -6254,11 +6661,11 @@ class TartubeApp(Gtk.Application): self.export_from_db( [] ) - def on_menu_import_db(self, action, par): + def on_menu_go_website(self, action, par): """Called from a callback in self.do_startup(). - Imports data into the Tartube database. + Opens the Tartube website. Args: @@ -6269,9 +6676,50 @@ class TartubeApp(Gtk.Application): """ if DEBUG_FUNC_FLAG: - print('ap 4159 on_menu_import_db') + print('ap 3782 on_menu_go_website') - self.import_into_db() + utils.open_file(__main__.__website__) + + + def on_menu_import_json(self, action, par): + + """Called from a callback in self.do_startup(). + + Imports data into from a JSON export file into the Tartube database. + + Args: + + action (Gio.SimpleAction): Object generated by Gio + + par (None): Ignored + + """ + + if DEBUG_FUNC_FLAG: + print('ap 4159 on_menu_import_json') + + self.import_into_db(True) + + + def on_menu_import_plain_text(self, action, par): + + """Called from a callback in self.do_startup(). + + Imports data into from a plain text export file into the Tartube + database. + + Args: + + action (Gio.SimpleAction): Object generated by Gio + + par (None): Ignored + + """ + + if DEBUG_FUNC_FLAG: + print('ap 4160 on_menu_import_plain_text') + + self.import_into_db(False) def on_menu_general_options(self, action, par): @@ -6523,6 +6971,36 @@ class TartubeApp(Gtk.Application): self.apply_json_timeout_flag = True + def set_auto_delete_flag(self, flag): + + if DEBUG_FUNC_FLAG: + print('ap 4414 set_auto_delete_flag') + + if not flag: + self.auto_delete_flag = False + else: + self.auto_delete_flag = True + + + def set_auto_delete_watched_flag(self, flag): + + if DEBUG_FUNC_FLAG: + print('ap 4415 set_auto_delete_watched_flag') + + if not flag: + self.auto_delete_watched_flag = False + else: + self.auto_delete_watched_flag = True + + + def set_auto_delete_days(self, days): + + if DEBUG_FUNC_FLAG: + print('ap 4415 set_auto_delete_days') + + self.auto_delete_days = days + + def set_bandwidth_default(self, value): """Called by mainwin.MainWin.on_spinbutton2_changed(). diff --git a/tartube/mainwin.py b/tartube/mainwin.py index 0a676c8..484566a 100755 --- a/tartube/mainwin.py +++ b/tartube/mainwin.py @@ -424,6 +424,14 @@ class MainWin(Gtk.ApplicationWindow): ) self.icon_dict[key] = full_path + # (At the moment, the system preference window only uses one + # flag, but more may be added later) + full_path = os.path.abspath( + os.path.join(icon_dir_path, 'locale', 'flag_uk.png'), + ) + self.icon_dict['flag_uk'] = full_path + + # Now create the pixbufs themselves for key in self.icon_dict: full_path = self.icon_dict[key] @@ -526,8 +534,7 @@ class MainWin(Gtk.ApplicationWindow): file_sub_menu.append(self.save_db_menu_item) self.save_db_menu_item.set_action_name('app.save_db_menu') - separator_item = Gtk.SeparatorMenuItem() - file_sub_menu.append(separator_item) + file_sub_menu.append(Gtk.SeparatorMenuItem()) quit_menu_item = Gtk.MenuItem.new_with_mnemonic('_Quit') file_sub_menu.append(quit_menu_item) @@ -583,8 +590,7 @@ class MainWin(Gtk.ApplicationWindow): media_sub_menu.append(add_folder_menu_item) add_folder_menu_item.set_action_name('app.add_folder_menu') - separator_item2 = Gtk.SeparatorMenuItem() - media_sub_menu.append(separator_item2) + media_sub_menu.append(Gtk.SeparatorMenuItem()) self.export_db_menu_item = Gtk.MenuItem.new_with_mnemonic( '_Export from database', @@ -592,14 +598,27 @@ class MainWin(Gtk.ApplicationWindow): media_sub_menu.append(self.export_db_menu_item) self.export_db_menu_item.set_action_name('app.export_db_menu') - self.import_db_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Import into database', - ) - media_sub_menu.append(self.import_db_menu_item) - self.import_db_menu_item.set_action_name('app.import_db_menu') + import_sub_menu = Gtk.Menu() - separator_item3 = Gtk.SeparatorMenuItem() - media_sub_menu.append(separator_item3) + import_json_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_JSON export file', + ) + import_sub_menu.append(import_json_menu_item) + import_json_menu_item.set_action_name('app.import_json_menu') + + import_text_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Plain _text export file', + ) + import_sub_menu.append(import_text_menu_item) + import_text_menu_item.set_action_name('app.import_text_menu') + + self.import_db_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Import into database' + ) + self.import_db_menu_item.set_submenu(import_sub_menu) + media_sub_menu.append(self.import_db_menu_item) + + media_sub_menu.append(Gtk.SeparatorMenuItem()) switch_view_menu_item = \ Gtk.MenuItem.new_with_mnemonic('_Switch between views') @@ -613,8 +632,7 @@ class MainWin(Gtk.ApplicationWindow): if self.app_obj.debug_test_media_menu_flag: - separator_item4 = Gtk.SeparatorMenuItem() - media_sub_menu.append(separator_item4) + media_sub_menu.append(Gtk.SeparatorMenuItem()) self.test_menu_item = Gtk.MenuItem.new_with_mnemonic( '_Add test media', @@ -644,8 +662,7 @@ class MainWin(Gtk.ApplicationWindow): self.stop_download_menu_item.set_sensitive(False) self.stop_download_menu_item.set_action_name('app.stop_download_menu') - separator_item5 = Gtk.SeparatorMenuItem() - ops_sub_menu.append(separator_item5) + ops_sub_menu.append(Gtk.SeparatorMenuItem()) self.refresh_db_menu_item = Gtk.MenuItem.new_with_mnemonic( '_Refresh database', @@ -653,15 +670,14 @@ class MainWin(Gtk.ApplicationWindow): ops_sub_menu.append(self.refresh_db_menu_item) self.refresh_db_menu_item.set_action_name('app.refresh_db_menu') - separator_item6 = Gtk.SeparatorMenuItem() - ops_sub_menu.append(separator_item6) + ops_sub_menu.append(Gtk.SeparatorMenuItem()) self.update_ytdl_menu_item = Gtk.MenuItem.new_with_mnemonic( '_Update youtube-dl', ) ops_sub_menu.append(self.update_ytdl_menu_item) self.update_ytdl_menu_item.set_action_name('app.update_ytdl_menu') - if __main__.__disable_ytdl_update_flag__: + if __main__.__debian_install_flag__: self.update_ytdl_menu_item.set_sensitive(False) # Help column @@ -675,6 +691,10 @@ class MainWin(Gtk.ApplicationWindow): help_sub_menu.append(about_menu_item) about_menu_item.set_action_name('app.about_menu') + go_website_menu_item = Gtk.MenuItem.new_with_mnemonic('Go to _website') + help_sub_menu.append(go_website_menu_item) + go_website_menu_item.set_action_name('app.go_website_menu') + def setup_main_toolbar(self): @@ -1172,11 +1192,16 @@ class MainWin(Gtk.ApplicationWindow): self.progress_list_treeview = Gtk.TreeView() self.progress_list_frame.add(self.progress_list_treeview) self.progress_list_treeview.set_can_focus(False) - + # (Detect right-clicks on the treeview) + self.progress_list_treeview.connect( + 'button-press-event', + self.on_progress_list_right_click, + ) + for i, column_title in enumerate( [ - '', 'Source', 'Videos', 'Status', 'Incoming file', 'Ext', - 'Size', '%', 'ETA', 'Speed', + 'hide', '', 'Source', 'Videos', 'Status', 'Incoming file', + 'Ext', 'Size', '%', 'ETA', 'Speed', ] ): if not column_title: @@ -1187,6 +1212,7 @@ class MainWin(Gtk.ApplicationWindow): pixbuf=i, ) self.progress_list_treeview.append_column(column_pixbuf) + column_pixbuf.set_resizable(False) else: renderer_text = Gtk.CellRendererText() @@ -1196,13 +1222,18 @@ class MainWin(Gtk.ApplicationWindow): text=i, ) self.progress_list_treeview.append_column(column_text) - + column_text.set_resizable(True) + column_text.set_min_width(20) + if column_title == 'hide': + column_text.set_visible(False) + self.progress_list_liststore = Gtk.ListStore( + int, GdkPixbuf.Pixbuf, str, str, str, str, str, str, str, str, str, ) self.progress_list_treeview.set_model(self.progress_list_liststore) - + # Lower half self.results_list_scrolled = Gtk.ScrolledWindow() self.progress_paned.add2(self.results_list_scrolled) @@ -1218,10 +1249,15 @@ class MainWin(Gtk.ApplicationWindow): self.results_list_treeview = Gtk.TreeView() self.results_list_frame.add(self.results_list_treeview) self.results_list_treeview.set_can_focus(False) - + # (Detect right-clicks on the treeview) + self.results_list_treeview.connect( + 'button-press-event', + self.on_results_list_right_click, + ) + for i, column_title in enumerate( [ - '', 'New videos', 'Duration', 'Size', 'Date', 'File', + 'hide', '', 'New videos', 'Duration', 'Size', 'Date', 'File', '', 'Downloaded to', ] ): @@ -1233,6 +1269,7 @@ class MainWin(Gtk.ApplicationWindow): pixbuf=i, ) self.results_list_treeview.append_column(column_pixbuf) + column_pixbuf.set_resizable(False) elif column_title == 'File': renderer_toggle = Gtk.CellRendererToggle() @@ -1242,6 +1279,7 @@ class MainWin(Gtk.ApplicationWindow): active=i, ) self.results_list_treeview.append_column(column_toggle) + column_toggle.set_resizable(False) else: renderer_text = Gtk.CellRendererText() @@ -1251,8 +1289,13 @@ class MainWin(Gtk.ApplicationWindow): text=i, ) self.results_list_treeview.append_column(column_text) - + column_text.set_resizable(True) + column_text.set_min_width(20) + if column_title == 'hide': + column_text.set_visible(False) + self.results_list_liststore = Gtk.ListStore( + int, GdkPixbuf.Pixbuf, str, str, str, str, bool, @@ -1401,7 +1444,7 @@ class MainWin(Gtk.ApplicationWindow): self.check_all_toolbutton.set_sensitive(flag) self.download_all_toolbutton.set_sensitive(flag) - if not __main__.__disable_ytdl_update_flag__: + if not __main__.__debian_install_flag__: self.update_ytdl_menu_item.set_sensitive(flag) # (The 'Save database' menu item must remain desensitised if file load/ @@ -1863,6 +1906,960 @@ class MainWin(Gtk.ApplicationWindow): return 0 + # (Popup menu functions for main window widgets) + + + def video_index_popup_menu(self, event, name): + + """Called by self.video_index_treeview_click_event(). + + When the user right-clicks on the Video Index, show a context-sensitive + popup menu. + + Args: + + event (Gdk.EventButton): The mouse click event + + name (string): The name of the clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 2174 video_index_popup_menu') + + # Find the right-clicked media data object (and a string to describe + # its type) + dbid = self.app_obj.media_name_dict[name] + media_data_obj = self.app_obj.media_reg_dict[dbid] + + if isinstance(media_data_obj, media.Channel): + media_type = 'channel' + elif isinstance(media_data_obj, media.Playlist): + media_type = 'playlist' + else: + media_type = 'folder' + + # Set up the popup menu + popup_menu = Gtk.Menu() + + # Check/download/refresh items + check_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Check ' + media_type, + ) + check_menu_item.connect( + 'activate', + self.on_video_index_check, + media_data_obj, + ) + if self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag + ): + check_menu_item.set_sensitive(False) + popup_menu.append(check_menu_item) + + download_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Download ' + media_type, + ) + download_menu_item.connect( + 'activate', + self.on_video_index_download, + media_data_obj, + ) + if self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag + ): + download_menu_item.set_sensitive(False) + popup_menu.append(download_menu_item) + + refresh_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Refresh ' + media_type, + ) + refresh_menu_item.connect( + 'activate', + self.on_video_index_refresh, + media_data_obj, + ) + if self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag + ): + refresh_menu_item.set_sensitive(False) + popup_menu.append(refresh_menu_item) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # Contents + contents_submenu = Gtk.Menu() + + if not isinstance(media_data_obj, media.Folder): + + self.video_index_setup_contents_submenu( + contents_submenu, + media_data_obj, + False, + ) + + else: + + # All contents + all_contents_submenu = Gtk.Menu() + + self.video_index_setup_contents_submenu( + all_contents_submenu, + media_data_obj, + False, + ) + + # Separator + all_contents_submenu.append(Gtk.SeparatorMenuItem()) + + empty_folder_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Empty folder', + ) + empty_folder_menu_item.connect( + 'activate', + self.on_video_index_empty_folder, + media_data_obj, + ) + all_contents_submenu.append(empty_folder_menu_item) + if not media_data_obj.child_list or media_data_obj.priv_flag: + empty_folder_menu_item.set_sensitive(False) + + all_contents_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_All contents', + ) + all_contents_menu_item.set_submenu(all_contents_submenu) + contents_submenu.append(all_contents_menu_item) + + # Just folder videos + just_videos_submenu = Gtk.Menu() + + self.video_index_setup_contents_submenu( + just_videos_submenu, + media_data_obj, + True, + ) + + # Separator + just_videos_submenu.append(Gtk.SeparatorMenuItem()) + + empty_videos_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Remove videos', + ) + empty_videos_menu_item.connect( + 'activate', + self.on_video_index_remove_videos, + media_data_obj, + ) + just_videos_submenu.append(empty_videos_menu_item) + if not media_data_obj.child_list or media_data_obj.priv_flag: + empty_videos_menu_item.set_sensitive(False) + + just_videos_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Just folder videos', + ) + just_videos_menu_item.set_submenu(just_videos_submenu) + contents_submenu.append(just_videos_menu_item) + + contents_menu_item = Gtk.MenuItem.new_with_mnemonic( + utils.upper_case_first(media_type) + ' co_ntents', + ) + contents_menu_item.set_submenu(contents_submenu) + popup_menu.append(contents_menu_item) + + # Actions + actions_submenu = Gtk.Menu() + + move_top_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Move to _top level', + ) + move_top_menu_item.connect( + 'activate', + self.on_video_index_move_to_top, + media_data_obj, + ) + actions_submenu.append(move_top_menu_item) + if not media_data_obj.parent_obj \ + or self.app_obj.current_manager_obj: + move_top_menu_item.set_sensitive(False) + + # Separator + actions_submenu.append(Gtk.SeparatorMenuItem()) + + if isinstance(media_data_obj, media.Folder): + + hide_folder_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Hide folder', + ) + hide_folder_menu_item.connect( + 'activate', + self.on_video_index_hide_folder, + media_data_obj, + ) + actions_submenu.append(hide_folder_menu_item) + + set_nickname_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Set _nickname...', + ) + set_nickname_menu_item.connect( + 'activate', + self.on_video_index_set_nickname, + media_data_obj, + ) + actions_submenu.append(set_nickname_menu_item) + if isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag: + set_nickname_menu_item.set_sensitive(False) + + set_destination_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Set _download destination...', + ) + set_destination_menu_item.connect( + 'activate', + self.on_video_index_set_destination, + media_data_obj, + ) + actions_submenu.append(set_destination_menu_item) + if isinstance(media_data_obj, media.Folder) \ + and media_data_obj.fixed_flag: + set_destination_menu_item.set_sensitive(False) + + # Separator + actions_submenu.append(Gtk.SeparatorMenuItem()) + + export_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Export ' + media_type + '...', + ) + export_menu_item.connect( + 'activate', + self.on_video_index_export, + media_data_obj, + ) + actions_submenu.append(export_menu_item) + if self.app_obj.current_manager_obj: + export_menu_item.set_sensitive(False) + + actions_menu_item = Gtk.MenuItem.new_with_mnemonic( + utils.upper_case_first(media_type) + ' _actions', + ) + actions_menu_item.set_submenu(actions_submenu) + popup_menu.append(actions_menu_item) + + # Apply/remove/edit download options, disable downloads + downloads_submenu = Gtk.Menu() + + # (Desensitise these menu items, if an edit window is already open) + no_options_flag = False + for win_obj in self.config_win_list: + if isinstance(win_obj, config.OptionsEditWin) \ + and media_data_obj.options_obj == win_obj.edit_obj: + no_options_flag = True + break + + if not media_data_obj.options_obj: + + apply_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Apply download _options...', + ) + apply_options_menu_item.connect( + 'activate', + self.on_video_index_apply_options, + media_data_obj, + ) + downloads_submenu.append(apply_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) + and media_data_obj.priv_flag + ): + apply_options_menu_item.set_sensitive(False) + + else: + + remove_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Remove download _options', + ) + remove_options_menu_item.connect( + 'activate', + self.on_video_index_remove_options, + media_data_obj, + ) + downloads_submenu.append(remove_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) + and media_data_obj.priv_flag + ): + remove_options_menu_item.set_sensitive(False) + + edit_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Edit download options...', + ) + edit_options_menu_item.connect( + 'activate', + self.on_video_index_edit_options, + media_data_obj, + ) + downloads_submenu.append(edit_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj \ + or not media_data_obj.options_obj: + edit_options_menu_item.set_sensitive(False) + + # Separator + downloads_submenu.append(Gtk.SeparatorMenuItem()) + + disable_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + 'D_isable checking/downloading', + ) + disable_menu_item.set_active(media_data_obj.dl_disable_flag) + disable_menu_item.connect( + 'activate', + self.on_video_index_dl_disable, + media_data_obj, + ) + downloads_submenu.append(disable_menu_item) + # (Widget sensitivity set below) + + enforce_check_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + '_Just disable downloading', + ) + enforce_check_menu_item.set_active(media_data_obj.dl_sim_flag) + enforce_check_menu_item.connect( + 'activate', + self.on_video_index_enforce_check, + media_data_obj, + ) + downloads_submenu.append(enforce_check_menu_item) + if self.app_obj.current_manager_obj or media_data_obj.dl_disable_flag \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.fixed_flag + ): + enforce_check_menu_item.set_sensitive(False) + + # (Widget sensitivity from above) + if self.app_obj.current_manager_obj \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.fixed_flag + ): + disable_menu_item.set_sensitive(False) + enforce_check_menu_item.set_sensitive(False) + + downloads_menu_item = Gtk.MenuItem.new_with_mnemonic('_Downloads') + downloads_menu_item.set_submenu(downloads_submenu) + popup_menu.append(downloads_menu_item) + + # Filesystem + filesystem_submenu = Gtk.Menu() + + show_location_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Show default location', + ) + show_location_menu_item.connect( + 'activate', + self.on_video_index_show_location, + media_data_obj, + ) + filesystem_submenu.append(show_location_menu_item) + if isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag: + show_location_menu_item.set_sensitive(False) + + rename_location_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Rename default location...', + ) + rename_location_menu_item.connect( + 'activate', + self.on_video_index_rename_location, + media_data_obj, + ) + filesystem_submenu.append(rename_location_menu_item) + if self.app_obj.current_manager_obj or self.config_win_list \ + or ( + isinstance(media_data_obj, media.Folder) \ + and media_data_obj.fixed_flag + ): + rename_location_menu_item.set_sensitive(False) + + # Separator + filesystem_submenu.append(Gtk.SeparatorMenuItem()) + + show_destination_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Show download destination', + ) + show_destination_menu_item.connect( + 'activate', + self.on_video_index_show_destination, + media_data_obj, + ) + filesystem_submenu.append(show_destination_menu_item) + if isinstance(media_data_obj, media.Folder) \ + and media_data_obj.priv_flag: + show_destination_menu_item.set_sensitive(False) + + filesystem_menu_item = Gtk.MenuItem.new_with_mnemonic('_Filesystem') + filesystem_menu_item.set_submenu(filesystem_submenu) + popup_menu.append(filesystem_menu_item) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + show_properties_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Show _properties...', + ) + show_properties_menu_item.connect( + 'activate', + self.on_video_index_show_properties, + media_data_obj, + ) + popup_menu.append(show_properties_menu_item) + if self.app_obj.current_manager_obj: + show_properties_menu_item.set_sensitive(False) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # Delete items + delete_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'De_lete ' + media_type, + ) + delete_menu_item.connect( + 'activate', + self.on_video_index_delete_container, + media_data_obj, + ) + if self.app_obj.current_manager_obj: + delete_menu_item.set_sensitive(False) + popup_menu.append(delete_menu_item) + + # Create the popup menu + popup_menu.show_all() + popup_menu.popup(None, None, None, None, event.button, event.time) + + + def video_catalogue_popup_menu(self, event, video_obj): + + """Called by mainwin.SimpleCatalogueItem.on_right_click() and + mainwin.ComplexCatalogueItem.on_right_click(). + + When the user right-clicks on the Video Catalogue, show a context- + sensitive popup menu. + + Args: + + event (Gdk.EventButton): The mouse click event + + video_obj (media.Video): The video object displayed in the clicked + row + + """ + + if DEBUG_FUNC_FLAG: + print('mw 2735 video_catalogue_popup_menu') + + # Set up the popup menu + popup_menu = Gtk.Menu() + + # Check/download videos + check_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Check video' + ) + check_menu_item.connect( + 'activate', + self.on_video_catalogue_check, + video_obj, + ) + if self.app_obj.current_manager_obj: + check_menu_item.set_sensitive(False) + popup_menu.append(check_menu_item) + + if not video_obj.dl_flag: + + download_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Download video' + ) + download_menu_item.connect( + 'activate', + self.on_video_catalogue_download, + video_obj, + ) + if self.app_obj.current_manager_obj: + download_menu_item.set_sensitive(False) + popup_menu.append(download_menu_item) + + else: + + download_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Re-_download this video' + ) + download_menu_item.connect( + 'activate', + self.on_video_catalogue_re_download, + video_obj, + ) + if self.app_obj.current_manager_obj: + download_menu_item.set_sensitive(False) + popup_menu.append(download_menu_item) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # Watch video + self.add_watch_video_menu_items(popup_menu, video_obj) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # New/favourite videos + new_video_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + 'Video is _new', + ) + new_video_menu_item.set_active(video_obj.new_flag) + new_video_menu_item.connect( + 'toggled', + self.on_video_catalogue_toggle_new_video, + video_obj, + ) + popup_menu.append(new_video_menu_item) + if not video_obj.dl_flag: + new_video_menu_item.set_sensitive(False) + + fav_video_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + 'Video is _favourite', + ) + fav_video_menu_item.set_active(video_obj.fav_flag) + fav_video_menu_item.connect( + 'toggled', + self.on_video_catalogue_toggle_favourite_video, + video_obj, + ) + popup_menu.append(fav_video_menu_item) + if not video_obj.dl_flag: + fav_video_menu_item.set_sensitive(False) + + archive_video_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + 'Video is _archived', + ) + archive_video_menu_item.set_active(video_obj.archive_flag) + archive_video_menu_item.connect( + 'toggled', + self.on_video_catalogue_toggle_archived_video, + video_obj, + ) + popup_menu.append(archive_video_menu_item) + if not video_obj.dl_flag: + archive_video_menu_item.set_sensitive(False) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # Apply/remove/edit download options, disable downloads + downloads_submenu = Gtk.Menu() + + # (Desensitise these menu items, if an edit window is already open) + no_options_flag = False + for win_obj in self.config_win_list: + if isinstance(win_obj, config.OptionsEditWin) \ + and video_obj.options_obj == win_obj.edit_obj: + no_options_flag = True + break + + if not video_obj.options_obj: + + apply_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Apply download _options...', + ) + apply_options_menu_item.connect( + 'activate', + self.on_video_catalogue_apply_options, + video_obj, + ) + downloads_submenu.append(apply_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj: + apply_options_menu_item.set_sensitive(False) + + else: + + remove_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Remove download _options', + ) + remove_options_menu_item.connect( + 'activate', + self.on_video_catalogue_remove_options, + video_obj, + ) + downloads_submenu.append(remove_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj: + remove_options_menu_item.set_sensitive(False) + + edit_options_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Edit download options...', + ) + edit_options_menu_item.connect( + 'activate', + self.on_video_catalogue_edit_options, + video_obj, + ) + downloads_submenu.append(edit_options_menu_item) + if no_options_flag or self.app_obj.current_manager_obj \ + or not video_obj.options_obj: + edit_options_menu_item.set_sensitive(False) + + # Separator + downloads_submenu.append(Gtk.SeparatorMenuItem()) + + enforce_check_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( + 'D_isable downloads', + ) + enforce_check_menu_item.set_active(video_obj.dl_sim_flag) + enforce_check_menu_item.connect( + 'activate', + self.on_video_catalogue_enforce_check, + video_obj, + ) + downloads_submenu.append(enforce_check_menu_item) + # (Don't allow the user to change the setting of + # media.Video.dl_sim_flag if the video is in a channel or playlist, + # since media.Channel.dl_sim_flag or media.Playlist.dl_sim_flag + # applies instead) + if self.app_obj.current_manager_obj \ + or not isinstance(video_obj.parent_obj, media.Folder): + enforce_check_menu_item.set_sensitive(False) + + downloads_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Downloads', + ) + downloads_menu_item.set_submenu(downloads_submenu) + popup_menu.append(downloads_menu_item) + + # Show properties + show_properties_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Show _properties...', + ) + show_properties_menu_item.connect( + 'activate', + self.on_video_catalogue_show_properties, + video_obj, + ) + popup_menu.append(show_properties_menu_item) + if self.app_obj.current_manager_obj: + show_properties_menu_item.set_sensitive(False) + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # Delete video + delete_menu_item = Gtk.MenuItem.new_with_mnemonic('De_lete video') + delete_menu_item.connect( + 'activate', + self.on_video_catalogue_delete_video, + video_obj, + ) + popup_menu.append(delete_menu_item) + + # Create the popup menu + popup_menu.show_all() + popup_menu.popup(None, None, None, None, event.button, event.time) + + + def progress_list_popup_menu(self, event, dbid): + + """Called by self.on_progress_list_right_click(). + + When the user right-clicks on the Progress List, show a context- + sensitive popup menu. + + Args: + + event (Gdk.EventButton): The mouse click event + + dbid (int): The dbid of the clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 3372 progress_list_popup_menu') + + # Find the downloads.VideoDownloader which is currently handling the + # clicked media data object (if any) + worker_obj = None + video_downloader_obj = None + + if self.app_obj.download_manager_obj: + for this_worker_obj \ + in self.app_obj.download_manager_obj.worker_list: + if this_worker_obj.running_flag \ + and this_worker_obj.download_item_obj.dbid == dbid \ + and this_worker_obj.video_downloader_obj is not None: + worker_obj = this_worker_obj + video_downloader_obj = this_worker_obj.video_downloader_obj + break + + # Set up the popup menu + popup_menu = Gtk.Menu() + + # Stop check/download + stop_now_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Stop now', + ) + stop_now_menu_item.connect( + 'activate', + self.on_progress_list_stop_now, + dbid, + worker_obj, + video_downloader_obj, + ) + popup_menu.append(stop_now_menu_item) + if not self.app_obj.download_manager_obj \ + or video_downloader_obj is None: + stop_now_menu_item.set_sensitive(False) + + stop_soon_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Stop after this _video', + ) + stop_soon_menu_item.connect( + 'activate', + self.on_progress_list_stop_soon, + dbid, + worker_obj, + video_downloader_obj, + ) + popup_menu.append(stop_soon_menu_item) + if not self.app_obj.download_manager_obj \ + or video_downloader_obj is None: + stop_soon_menu_item.set_sensitive(False) + + + # Create the popup menu + popup_menu.show_all() + popup_menu.popup(None, None, None, None, event.button, event.time) + + + def results_list_popup_menu(self, event, path, dbid): + + """Called by self.on_results_list_right_click(). + + When the user right-clicks on the Results List, show a context- + sensitive popup menu. + + Args: + + event (Gdk.EventButton): The mouse click event + + path (Gtk.TreePath): Path to the clicked row in the treeview + + dbid (int): The dbid of the clicked video object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 3372 video_index_popup_menu') + + # Find the right-clicked video object, and check it still exists + if not dbid in self.app_obj.media_reg_dict: + return + + video_obj = self.app_obj.media_reg_dict[dbid] + if not isinstance(video_obj, media.Video): + return + + # Set up the popup menu + popup_menu = Gtk.Menu() + + # Watch video + self.add_watch_video_menu_items(popup_menu, video_obj) + + # Separator + separator_item = Gtk.SeparatorMenuItem() + popup_menu.append(separator_item) + + # Delete video + delete_menu_item = Gtk.MenuItem.new_with_mnemonic('_Delete video') + delete_menu_item.connect( + 'activate', + self.on_results_list_delete_video, + video_obj, + path, + ) + popup_menu.append(delete_menu_item) + if self.app_obj.current_manager_obj: + delete_menu_item.set_sensitive(False) + + # Create the popup menu + popup_menu.show_all() + popup_menu.popup(None, None, None, None, event.button, event.time) + + + def video_index_setup_contents_submenu(self, submenu, media_data_obj, + only_child_videos_flag=False): + + """Called by self.video_index_popup_menu(). + + Sets up a submenu for handling the contents of a channel, playlist + or folder. + + Args: + + submenu (Gtk.Menu): The submenu to set up, currently empty + + media_data_obj (media.Channel, media.Playlist, media.Folder): The + channel, playlist or folder whose contents should be modified + by items in the sub-menu + + only_child_videos_flag (True or False): Set to True when only a + folder's child videos (not anything in its child channels, + playlists or folders) should be modified by items in the + sub-menu; False if all child objects should be modified + + """ + + mark_new_menu_item = Gtk.MenuItem.new_with_mnemonic('Mark as _new') + mark_new_menu_item.connect( + 'activate', + self.on_video_index_mark_new, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_new_menu_item) + if media_data_obj == self.app_obj.fixed_new_folder: + mark_new_menu_item.set_sensitive(False) + + mark_old_menu_item = Gtk.MenuItem.new_with_mnemonic('Mark as n_ot new') + mark_old_menu_item.connect( + 'activate', + self.on_video_index_mark_not_new, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_old_menu_item) + + # Separator + submenu.append(Gtk.SeparatorMenuItem()) + + mark_fav_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Mark as _favourite', + ) + mark_fav_menu_item.connect( + 'activate', + self.on_video_index_mark_favourite, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_fav_menu_item) + if media_data_obj == self.app_obj.fixed_fav_folder: + mark_fav_menu_item.set_sensitive(False) + + mark_not_fav_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Mark as not f_avourite', + ) + mark_not_fav_menu_item.connect( + 'activate', + self.on_video_index_mark_not_favourite, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_not_fav_menu_item) + + # Separator + submenu.append(Gtk.SeparatorMenuItem()) + + mark_archived_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Mark videos as _archived', + ) + mark_archived_menu_item.connect( + 'activate', + self.on_video_index_mark_archived, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_archived_menu_item) + + mark_not_archive_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Mark videos as not ar_chived', + ) + mark_not_archive_menu_item.connect( + 'activate', + self.on_video_index_mark_not_archived, + media_data_obj, + only_child_videos_flag, + ) + submenu.append(mark_not_archive_menu_item) + + + def add_watch_video_menu_items(self, popup_menu, video_obj): + + """Called by self.video_catalogue_popup_menu() and + self.results_list_popup_menu(). + + Adds common menu items to the popup menu. + + Args: + + popup_menu (Gtk.Menu): The popup menu + + video_obj (media.Video): The video object that was right-clicked + + """ + + # Watch video in player + watch_player_menu_item = Gtk.MenuItem.new_with_mnemonic( + '_Watch in _player', + ) + watch_player_menu_item.connect( + 'activate', + self.on_video_catalogue_watch_video, + video_obj, + ) + if not video_obj.dl_flag: + watch_player_menu_item.set_sensitive(False) + popup_menu.append(watch_player_menu_item) + + # Watch video online. For YouTube URLs, offer an alternative website + if not video_obj.source: + watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'W_atch on website', + ) + else: + mod_source = utils.convert_youtube_to_hooktube(video_obj.source) + if video_obj.source != mod_source: + watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'W_atch on YouTube', + ) + + else: + watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'W_atch on website', + ) + + watch_website_menu_item.connect( + 'activate', + self.on_video_catalogue_watch_website, + video_obj, + ) + if not video_obj.source: + watch_website_menu_item.set_sensitive(False) + popup_menu.append(watch_website_menu_item) + + if video_obj.source and video_obj.source != mod_source: + + watch_hooktube_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Watch on _HookTube', + ) + watch_hooktube_menu_item.connect( + 'activate', + self.on_video_catalogue_watch_hooktube, + video_obj, + ) + popup_menu.append(watch_hooktube_menu_item) + + # (Video Index) @@ -2573,446 +3570,15 @@ class MainWin(Gtk.ApplicationWindow): renderer.set_property('weight', Pango.Weight.NORMAL) # If downloads disabled, show as italic text - if media_data_obj.dl_sim_flag: + if media_data_obj.dl_disable_flag: renderer.set_property('style', Pango.Style.ITALIC) + renderer.set_property('underline', True) + elif media_data_obj.dl_sim_flag: + renderer.set_property('style', Pango.Style.ITALIC) + renderer.set_property('underline', False) else: renderer.set_property('style', Pango.Style.NORMAL) - - - def video_index_popup_menu(self, event, name): - - """Called by self.video_index_treeview_click_event(). - - When the user right-clicks on the Video Index, show a context-sensitive - popup menu. - - Args: - - event (Gdk.EventButton): The mouse click event - - name (string): The name of the clicked media data object - - """ - - if DEBUG_FUNC_FLAG: - print('mw 2174 video_index_popup_menu') - - # Find the right-clicked media data object (and a string to describe - # its type) - dbid = self.app_obj.media_name_dict[name] - media_data_obj = self.app_obj.media_reg_dict[dbid] - - if isinstance(media_data_obj, media.Channel): - media_type = 'channel' - elif isinstance(media_data_obj, media.Playlist): - media_type = 'playlist' - else: - media_type = 'folder' - - # Set up the popup menu - popup_menu = Gtk.Menu() - - # Check/download/refresh items - check_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Check ' + media_type, - ) - check_menu_item.connect( - 'activate', - self.on_video_index_check, - media_data_obj, - ) - if self.app_obj.current_manager_obj \ - or ( - isinstance(media_data_obj, media.Folder) \ - and media_data_obj.priv_flag - ): - check_menu_item.set_sensitive(False) - popup_menu.append(check_menu_item) - - download_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Download ' + media_type, - ) - download_menu_item.connect( - 'activate', - self.on_video_index_download, - media_data_obj, - ) - if self.app_obj.current_manager_obj \ - or ( - isinstance(media_data_obj, media.Folder) \ - and media_data_obj.priv_flag - ): - download_menu_item.set_sensitive(False) - popup_menu.append(download_menu_item) - - refresh_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Refresh ' + media_type, - ) - refresh_menu_item.connect( - 'activate', - self.on_video_index_refresh, - media_data_obj, - ) - if self.app_obj.current_manager_obj \ - or ( - isinstance(media_data_obj, media.Folder) \ - and media_data_obj.priv_flag - ): - refresh_menu_item.set_sensitive(False) - popup_menu.append(refresh_menu_item) - - # Separator - popup_menu.append(Gtk.SeparatorMenuItem()) - - # Apply/remove/edit download options, disable downloads - - # (Desensitise these menu items, if an edit window is already open) - no_options_flag = False - for win_obj in self.config_win_list: - if isinstance(win_obj, config.OptionsEditWin) \ - and media_data_obj.options_obj == win_obj.edit_obj: - no_options_flag = True - break - - if not media_data_obj.options_obj: - - apply_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Apply download _options...', - ) - apply_options_menu_item.connect( - 'activate', - self.on_video_index_apply_options, - media_data_obj, - ) - popup_menu.append(apply_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj \ - or ( - isinstance(media_data_obj, media.Folder) - and media_data_obj.priv_flag - ): - apply_options_menu_item.set_sensitive(False) - - else: - - remove_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Remove download _options', - ) - remove_options_menu_item.connect( - 'activate', - self.on_video_index_remove_options, - media_data_obj, - ) - popup_menu.append(remove_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj \ - or ( - isinstance(media_data_obj, media.Folder) - and media_data_obj.priv_flag - ): - remove_options_menu_item.set_sensitive(False) - - edit_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Edit download options...', - ) - edit_options_menu_item.connect( - 'activate', - self.on_video_index_edit_options, - media_data_obj, - ) - popup_menu.append(edit_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj \ - or not media_data_obj.options_obj: - edit_options_menu_item.set_sensitive(False) - - enforce_check_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( - 'D_isable video downloads', - ) - enforce_check_menu_item.set_active(media_data_obj.dl_sim_flag) - enforce_check_menu_item.connect( - 'activate', - self.on_video_index_enforce_check, - media_data_obj, - ) - popup_menu.append(enforce_check_menu_item) - if self.app_obj.current_manager_obj: - enforce_check_menu_item.set_sensitive(False) - - # Separator - popup_menu.append(Gtk.SeparatorMenuItem()) - - # Contents - contents_submenu = Gtk.Menu() - - if not isinstance(media_data_obj, media.Folder): - - self.video_index_setup_contents_submenu( - contents_submenu, - media_data_obj, - False, - ) - - else: - - # All contents - all_contents_submenu = Gtk.Menu() - - self.video_index_setup_contents_submenu( - all_contents_submenu, - media_data_obj, - False, - ) - - # Separator - all_contents_submenu.append(Gtk.SeparatorMenuItem()) - - empty_folder_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Empty folder', - ) - empty_folder_menu_item.connect( - 'activate', - self.on_video_index_empty_folder, - media_data_obj, - ) - all_contents_submenu.append(empty_folder_menu_item) - if not media_data_obj.child_list or media_data_obj.priv_flag: - empty_folder_menu_item.set_sensitive(False) - - all_contents_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_All contents', - ) - all_contents_menu_item.set_submenu(all_contents_submenu) - contents_submenu.append(all_contents_menu_item) - - # Just folder videos - just_videos_submenu = Gtk.Menu() - - self.video_index_setup_contents_submenu( - just_videos_submenu, - media_data_obj, - True, - ) - - # Separator - just_videos_submenu.append(Gtk.SeparatorMenuItem()) - - empty_videos_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Remove videos', - ) - empty_videos_menu_item.connect( - 'activate', - self.on_video_index_remove_videos, - media_data_obj, - ) - just_videos_submenu.append(empty_videos_menu_item) - if not media_data_obj.child_list or media_data_obj.priv_flag: - empty_videos_menu_item.set_sensitive(False) - - just_videos_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Just folder videos', - ) - just_videos_menu_item.set_submenu(just_videos_submenu) - contents_submenu.append(just_videos_menu_item) - - contents_menu_item = Gtk.MenuItem.new_with_mnemonic( - utils.upper_case_first(media_type) + ' co_ntents', - ) - contents_menu_item.set_submenu(contents_submenu) - popup_menu.append(contents_menu_item) - - # Actions - actions_submenu = Gtk.Menu() - - move_top_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Move to _top level', - ) - move_top_menu_item.connect( - 'activate', - self.on_video_index_move_to_top, - media_data_obj, - ) - actions_submenu.append(move_top_menu_item) - if not media_data_obj.parent_obj \ - or self.app_obj.current_manager_obj: - move_top_menu_item.set_sensitive(False) - - if isinstance(media_data_obj, media.Folder): - - hide_folder_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Hide folder', - ) - hide_folder_menu_item.connect( - 'activate', - self.on_video_index_hide_folder, - media_data_obj, - ) - actions_submenu.append(hide_folder_menu_item) - - set_nickname_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Set _nickname...', - ) - set_nickname_menu_item.connect( - 'activate', - self.on_video_index_set_nickname, - media_data_obj, - ) - actions_submenu.append(set_nickname_menu_item) - if isinstance(media_data_obj, media.Folder) \ - and media_data_obj.priv_flag: - set_nickname_menu_item.set_sensitive(False) - - export_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Export ' + media_type + '...', - ) - export_menu_item.connect( - 'activate', - self.on_video_index_export, - media_data_obj, - ) - actions_submenu.append(export_menu_item) - if self.app_obj.current_manager_obj: - export_menu_item.set_sensitive(False) - - # Separator - actions_submenu.append(Gtk.SeparatorMenuItem()) - - show_properties_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Show _properties...', - ) - show_properties_menu_item.connect( - 'activate', - self.on_video_index_show_properties, - media_data_obj, - ) - actions_submenu.append(show_properties_menu_item) - if self.app_obj.current_manager_obj: - show_properties_menu_item.set_sensitive(False) - - actions_menu_item = Gtk.MenuItem.new_with_mnemonic( - utils.upper_case_first(media_type) + ' _actions', - ) - actions_menu_item.set_submenu(actions_submenu) - popup_menu.append(actions_menu_item) - - # Filesystem - filesystem_submenu = Gtk.Menu() - - show_destination_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Show location', - ) - show_destination_menu_item.connect( - 'activate', - self.on_video_index_show_location, - media_data_obj, - ) - filesystem_submenu.append(show_destination_menu_item) - if isinstance(media_data_obj, media.Folder) \ - and media_data_obj.priv_flag: - show_destination_menu_item.set_sensitive(False) - - rename_destination_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Rename location...', - ) - rename_destination_menu_item.connect( - 'activate', - self.on_video_index_rename_location, - media_data_obj, - ) - filesystem_submenu.append(rename_destination_menu_item) - if self.app_obj.current_manager_obj or self.config_win_list \ - or ( - isinstance(media_data_obj, media.Folder) \ - and media_data_obj.fixed_flag - ): - rename_destination_menu_item.set_sensitive(False) - - filesystem_menu_item = Gtk.MenuItem.new_with_mnemonic('_Filesystem') - filesystem_menu_item.set_submenu(filesystem_submenu) - popup_menu.append(filesystem_menu_item) - - # Separator - popup_menu.append(Gtk.SeparatorMenuItem()) - - # Delete items - delete_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'De_lete ' + media_type, - ) - delete_menu_item.connect( - 'activate', - self.on_video_index_delete_container, - media_data_obj, - ) - if self.app_obj.current_manager_obj: - delete_menu_item.set_sensitive(False) - popup_menu.append(delete_menu_item) - - # Create the popup menu - popup_menu.show_all() - popup_menu.popup(None, None, None, None, event.button, event.time) - - - def video_index_setup_contents_submenu(self, submenu, media_data_obj, - only_child_videos_flag=False): - - """Called by self.video_index_popup_menu(). - - Sets up a submenu for handling the contents of a channel, playlist - or folder. - - Args: - - submenu (Gtk.Menu): The submenu to set up, currently empty - - media_data_obj (media.Channel, media.Playlist, media.Folder): The - channel, playlist or folder whose contents should be modified - by items in the sub-menu - - only_child_videos_flag (True or False): Set to True when only a - folder's child videos (not anything in its child channels, - playlists or folders) should be modified by items in the - sub-menu; False if all child objects should be modified - - """ - - mark_new_menu_item = Gtk.MenuItem.new_with_mnemonic('Mark as _new') - mark_new_menu_item.connect( - 'activate', - self.on_video_index_mark_new, - media_data_obj, - only_child_videos_flag, - ) - submenu.append(mark_new_menu_item) - if media_data_obj == self.app_obj.fixed_new_folder: - mark_new_menu_item.set_sensitive(False) - - mark_old_menu_item = Gtk.MenuItem.new_with_mnemonic('Mark as n_ot new') - mark_old_menu_item.connect( - 'activate', - self.on_video_index_mark_not_new, - media_data_obj, - only_child_videos_flag, - ) - submenu.append(mark_old_menu_item) - - mark_fav_menu_item = Gtk.MenuItem.new_with_mnemonic('Mark _favourite') - mark_fav_menu_item.connect( - 'activate', - self.on_video_index_mark_favourite, - media_data_obj, - only_child_videos_flag, - ) - submenu.append(mark_fav_menu_item) - if media_data_obj == self.app_obj.fixed_fav_folder: - mark_fav_menu_item.set_sensitive(False) - - mark_not_fav_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Mark not f_avourite', - ) - mark_not_fav_menu_item.connect( - 'activate', - self.on_video_index_mark_not_favourite, - media_data_obj, - only_child_videos_flag, - ) - submenu.append(mark_not_fav_menu_item) + renderer.set_property('underline', False) # (Video Catalogue) @@ -3499,264 +4065,6 @@ class MainWin(Gtk.ApplicationWindow): self.catalogue_listbox.show_all() - def video_catalogue_popup_menu(self, event, video_obj): - - """Called by mainwin.SimpleCatalogueItem.on_right_click() and - mainwin.ComplexCatalogueItem.on_right_click(). - - When the user right-clicks on the Video Catalogue, show a context- - sensitive popup menu. - - Args: - - event (Gdk.EventButton): The mouse click event - - video_obj (media.Video): The video object displayed in the clicked - row - - """ - - if DEBUG_FUNC_FLAG: - print('mw 2735 video_catalogue_popup_menu') - - # Set up the popup menu - popup_menu = Gtk.Menu() - - # Check/download videos - check_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Check video' - ) - check_menu_item.connect( - 'activate', - self.on_video_catalogue_check, - video_obj, - ) - if self.app_obj.current_manager_obj: - check_menu_item.set_sensitive(False) - popup_menu.append(check_menu_item) - - if not video_obj.dl_flag: - - download_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Download video' - ) - download_menu_item.connect( - 'activate', - self.on_video_catalogue_download, - video_obj, - ) - if self.app_obj.current_manager_obj: - download_menu_item.set_sensitive(False) - popup_menu.append(download_menu_item) - - else: - - download_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Re-_download this video' - ) - download_menu_item.connect( - 'activate', - self.on_video_catalogue_re_download, - video_obj, - ) - if self.app_obj.current_manager_obj: - download_menu_item.set_sensitive(False) - popup_menu.append(download_menu_item) - - # Separator - separator_item = Gtk.SeparatorMenuItem() - popup_menu.append(separator_item) - - # Watch video in player - watch_player_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Watch in _player', - ) - watch_player_menu_item.connect( - 'activate', - self.on_video_catalogue_watch_video, - video_obj, - ) - if not video_obj.dl_flag: - watch_player_menu_item.set_sensitive(False) - popup_menu.append(watch_player_menu_item) - - # Watch video online. For YouTube URLs, offer an alternative website - if not video_obj.source: - watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'W_atch on website', - ) - else: - mod_source = utils.convert_youtube_to_hooktube(video_obj.source) - if video_obj.source != mod_source: - watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'W_atch on YouTube', - ) - - else: - watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'W_atch on website', - ) - - watch_website_menu_item.connect( - 'activate', - self.on_video_catalogue_watch_website, - video_obj, - ) - if not video_obj.source: - watch_website_menu_item.set_sensitive(False) - popup_menu.append(watch_website_menu_item) - - if video_obj.source and video_obj.source != mod_source: - - watch_hooktube_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Watch on _HookTube', - ) - watch_hooktube_menu_item.connect( - 'activate', - self.on_video_catalogue_watch_hooktube, - video_obj, - ) - popup_menu.append(watch_hooktube_menu_item) - - # Separator - separator_item2 = Gtk.SeparatorMenuItem() - popup_menu.append(separator_item2) - - # New/favourite videos - new_video_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( - 'Video is _new', - ) - new_video_menu_item.set_active(video_obj.new_flag) - new_video_menu_item.connect( - 'toggled', - self.on_video_catalogue_toggle_new_video, - video_obj, - ) - popup_menu.append(new_video_menu_item) - if not video_obj.dl_flag: - new_video_menu_item.set_sensitive(False) - - fav_video_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( - 'Video is _favourite', - ) - fav_video_menu_item.set_active(video_obj.fav_flag) - fav_video_menu_item.connect( - 'toggled', - self.on_video_catalogue_toggle_favourite_video, - video_obj, - ) - popup_menu.append(fav_video_menu_item) - if not video_obj.dl_flag: - fav_video_menu_item.set_sensitive(False) - - # Separator - separator_item3 = Gtk.SeparatorMenuItem() - popup_menu.append(separator_item3) - - # Apply/remove/edit download options, disable downloads - - # (Desensitise these menu items, if an edit window is already open) - no_options_flag = False - for win_obj in self.config_win_list: - if isinstance(win_obj, config.OptionsEditWin) \ - and video_obj.options_obj == win_obj.edit_obj: - no_options_flag = True - break - - if not video_obj.options_obj: - - apply_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Apply download _options...', - ) - apply_options_menu_item.connect( - 'activate', - self.on_video_catalogue_apply_options, - video_obj, - ) - popup_menu.append(apply_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj: - apply_options_menu_item.set_sensitive(False) - - else: - - remove_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Remove download _options', - ) - remove_options_menu_item.connect( - 'activate', - self.on_video_catalogue_remove_options, - video_obj, - ) - popup_menu.append(remove_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj: - remove_options_menu_item.set_sensitive(False) - - edit_options_menu_item = Gtk.MenuItem.new_with_mnemonic( - '_Edit download options...', - ) - edit_options_menu_item.connect( - 'activate', - self.on_video_catalogue_edit_options, - video_obj, - ) - popup_menu.append(edit_options_menu_item) - if no_options_flag or self.app_obj.current_manager_obj \ - or not video_obj.options_obj: - edit_options_menu_item.set_sensitive(False) - - enforce_check_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( - 'D_isable downloads', - ) - enforce_check_menu_item.set_active(video_obj.dl_sim_flag) - enforce_check_menu_item.connect( - 'activate', - self.on_video_catalogue_enforce_check, - video_obj, - ) - popup_menu.append(enforce_check_menu_item) - # (Don't allow the user to change the setting of - # media.Video.dl_sim_flag if the video is in a channel or playlist, - # since media.Channel.dl_sim_flag or media.Playlist.dl_sim_flag - # applies instead) - if self.app_obj.current_manager_obj \ - or not isinstance(video_obj.parent_obj, media.Folder): - enforce_check_menu_item.set_sensitive(False) - - # Separator - separator_item4 = Gtk.SeparatorMenuItem() - popup_menu.append(separator_item4) - - # Show properties - show_properties_menu_item = Gtk.MenuItem.new_with_mnemonic( - 'Show _properties...', - ) - show_properties_menu_item.connect( - 'activate', - self.on_video_catalogue_show_properties, - video_obj, - ) - popup_menu.append(show_properties_menu_item) - if self.app_obj.current_manager_obj: - show_properties_menu_item.set_sensitive(False) - - # Separator - separator_item5 = Gtk.SeparatorMenuItem() - popup_menu.append(separator_item5) - - # Delete video - delete_menu_item = Gtk.MenuItem.new_with_mnemonic('De_lete video') - delete_menu_item.connect( - 'activate', - self.on_video_catalogue_delete_video, - video_obj, - ) - popup_menu.append(delete_menu_item) - - # Create the popup menu - popup_menu.show_all() - popup_menu.popup(None, None, None, None, event.button, event.time) - - def video_catalogue_toolbar_reset(self): """Called by self.video_catalogue_redraw_all(). @@ -3856,6 +4164,7 @@ class MainWin(Gtk.ApplicationWindow): # Reset widgets self.progress_list_liststore = Gtk.ListStore( + int, GdkPixbuf.Pixbuf, str, str, str, str, str, str, str, str, str, ) @@ -3913,6 +4222,7 @@ class MainWin(Gtk.ApplicationWindow): # Prepare the new row in the treeview row_list = [] + row_list.append(dbid) row_list.append(pixbuf) row_list.append( utils.shorten_string( @@ -4026,9 +4336,9 @@ class MainWin(Gtk.ApplicationWindow): tree_path = Gtk.TreePath(self.progress_list_row_dict[dbid]) # Update statistics displayed in that row - # (Columns 0 and 1 are not modified, once the row has been added to - # the treeview) - column = 1 + # (Columns 0, 1 and 2 are not modified, once the row has been added + # to the treeview) + column = 2 for key in ( 'playlist_index', @@ -4092,6 +4402,7 @@ class MainWin(Gtk.ApplicationWindow): # Reset widgets self.results_list_liststore = Gtk.ListStore( + int, GdkPixbuf.Pixbuf, str, str, str, str, bool, @@ -4163,6 +4474,7 @@ class MainWin(Gtk.ApplicationWindow): row_list = [] # Set the row's initial contents + row_list.append(video_obj.dbid) row_list.append(pixbuf) row_list.append( utils.shorten_string(video_obj.nickname, self.string_max_len), @@ -4314,7 +4626,7 @@ class MainWin(Gtk.ApplicationWindow): self.results_list_liststore.set( row_iter, - 1, + 2, utils.shorten_string( video_obj.nickname, self.string_max_len, @@ -4324,7 +4636,7 @@ class MainWin(Gtk.ApplicationWindow): if video_obj.duration is not None: self.results_list_liststore.set( row_iter, - 2, + 3, utils.convert_seconds_to_string( video_obj.duration, ), @@ -4333,23 +4645,23 @@ class MainWin(Gtk.ApplicationWindow): if video_obj.file_size: self.results_list_liststore.set( row_iter, - 3, + 4, video_obj.get_file_size_string(), ) if video_obj.upload_time: self.results_list_liststore.set( row_iter, - 4, + 5, video_obj.get_upload_date_string(), ) - self.results_list_liststore.set(row_iter, 5, video_obj.dl_flag) - self.results_list_liststore.set(row_iter, 6, pixbuf) + self.results_list_liststore.set(row_iter, 6, video_obj.dl_flag) + self.results_list_liststore.set(row_iter, 7, pixbuf) self.results_list_liststore.set( row_iter, - 7, + 8, utils.shorten_string( video_obj.parent_obj.name, self.string_max_len, @@ -4713,6 +5025,38 @@ class MainWin(Gtk.ApplicationWindow): ) + def on_video_index_dl_disable(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Set the media data object's flag to disable checking and downloading. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Channel): + The clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 3740 on_video_index_dl_disable') + + if self.app_obj.current_manager_obj: + return self.app_obj.system_error( + 236, + 'Callback request denied due to current conditions', + ) + + if not media_data_obj.dl_disable_flag: + media_data_obj.set_dl_disable_flag(True) + else: + media_data_obj.set_dl_disable_flag(False) + + self.video_index_update_row_text(media_data_obj) + + def on_video_index_check(self, menu_item, media_data_obj): """Called from a callback in self.video_index_popup_menu(). @@ -4987,13 +5331,75 @@ class MainWin(Gtk.ApplicationWindow): self.app_obj.export_from_db( [media_data_obj] ) + def on_video_index_mark_archived(self, menu_item, media_data_obj, + only_child_videos_flag): + + """Called from a callback in self.video_index_popup_menu(). + + Mark all of the children of this channel, playlist or folder (and all + of their chidlren, and so on) as archived. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Channel): + The clicked media data object + + only_child_videos_flag (True or False): Set to True if only child + video objects should be marked; False if all descendants should + be marked + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4010 on_video_index_mark_archived') + + self.app_obj.mark_container_archived( + media_data_obj, + True, + only_child_videos_flag, + ) + + + def on_video_index_mark_not_archived(self, menu_item, media_data_obj, + only_child_videos_flag): + + """Called from a callback in self.video_index_popup_menu(). + + Mark all videos in this folder (and in any child channels, playlists + and folders) as not archived. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Channel): + The clicked media data object + + only_child_videos_flag (True or False): Set to True if only child + video objects should be marked; False if all descendants should + be marked + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4032 on_video_index_mark_not_archived') + + self.app_obj.mark_container_archived( + media_data_obj, + False, + only_child_videos_flag, + ) + + def on_video_index_mark_favourite(self, menu_item, media_data_obj, only_child_videos_flag): """Called from a callback in self.video_index_popup_menu(). Mark all of the children of this channel, playlist or folder (and all - of their chidlren, and so on ) as favourite. + of their chidlren, and so on) as favourite. Args: @@ -5024,7 +5430,7 @@ class MainWin(Gtk.ApplicationWindow): """Called from a callback in self.video_index_popup_menu(). Mark all videos in this folder (and in any child channels, playlists - and folders) as not new. + and folders) as not favourite. Args: @@ -5411,6 +5817,44 @@ class MainWin(Gtk.ApplicationWindow): self.video_catalogue_redraw_all(name, 1, True) + def on_video_index_set_destination(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Sets (or resets) the alternative download destination for the selected + channel, playlist or folder. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Channel): + The clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4293 on_video_index_set_destination') + + if isinstance(media_data_obj, media.Video): + return self.app_obj.system_error( + 235, + 'Cannot set the download destination of a video', + ) + + dialogue_win = SetDestinationDialogue(self, media_data_obj) + response = dialogue_win.run() + + # Retrieve user choices from the dialogue window, before destroying it + dbid = dialogue_win.choice + dialogue_win.destroy() + + if response == Gtk.ResponseType.OK: + + if dbid != media_data_obj.master_dbid: + media_data_obj.set_master_dbid(self.app_obj, dbid) + + def on_video_index_set_nickname(self, menu_item, media_data_obj): """Called from a callback in self.video_index_popup_menu(). @@ -5453,12 +5897,38 @@ class MainWin(Gtk.ApplicationWindow): self.video_index_update_row_text(media_data_obj) + def on_video_index_show_destination(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Opens the sub-directory in which downloads for the specified media data + object are stored (which might be the default sub-directory for + another media data object, if the media data object's .master_dbid has + been modified). + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Channel): + The clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4330 on_video_index_show_destination') + + other_obj = self.app_obj.media_reg_dict[media_data_obj.master_dbid] + path = other_obj.get_dir(self.app_obj) + utils.open_file(path) + + def on_video_index_show_location(self, menu_item, media_data_obj): """Called from a callback in self.video_index_popup_menu(). - Opens the sub-directory folder in which downloads for the specified - media data object are stored. + Opens the sub-directory in which downloads for the specified media data + object are stored (by default). Args: @@ -5581,7 +6051,7 @@ class MainWin(Gtk.ApplicationWindow): if DEBUG_FUNC_FLAG: print('mw 4439 on_video_catalogue_delete_video') - self.app_obj.delete_video(media_data_obj, False, True) + self.app_obj.delete_video(media_data_obj, True) def on_video_catalogue_download(self, menu_item, media_data_obj): @@ -5849,6 +6319,32 @@ class MainWin(Gtk.ApplicationWindow): config.VideoEditWin(self.app_obj, media_data_obj) + def on_video_catalogue_toggle_archived_video(self, menu_item, \ + media_data_obj): + + """Called from a callback in self.video_catalogue_popup_menu(). + + Marks the video as archived or not archived. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Video) - The clicked video object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4658 on_video_catalogue_toggle_archived_video') + + if not media_data_obj.archive_flag: + media_data_obj.set_archive_flag(True) + else: + media_data_obj.set_archive_flag(False) + + self.video_catalogue_update_row(media_data_obj) + + def on_video_catalogue_toggle_favourite_video(self, menu_item, \ media_data_obj): @@ -5974,6 +6470,197 @@ class MainWin(Gtk.ApplicationWindow): self.app_obj.mark_video_new(media_data_obj, False) + def on_progress_list_right_click(self, treeview, event): + + """Called from callback in self.setup_progress_tab(). + + When the user right-clicks an item in the Progress List, create a + context-sensitive popup menu. + + Args: + + treeview (Gtk.TreeView): The Progress List's treeview + + event (Gdk.EventButton): The event emitting the Gtk signal + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4758 on_progress_list_right_click') + + if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: + + # If the user right-clicked on empty space, the call to + # .get_path_at_pos returns None (or an empty list) + if not treeview.get_path_at_pos( + int(event.x), + int(event.y), + ): + return + + path, column, cellx, celly = treeview.get_path_at_pos( + int(event.x), + int(event.y), + ) + + iter = self.progress_list_liststore.get_iter(path) + if iter is not None: + self.progress_list_popup_menu( + event, + self.progress_list_liststore[iter][0], + ) + + + def on_progress_list_stop_now(self, menu_item, dbid, worker_obj, + video_downloader_obj): + + """Called from a callback in self.progress_list_popup_menu(). + + Halts checking/downloading the selected media data object. + + Args: + + menu_item (Gtk.MenuItem): The menu item that was clicked + + dbid (int): The dbid of the selected media data object + + worker_obj (downloads.DownloadWorker): The worker currently + handling checking/downloading this media data object + + video_downloader_obj (downloads.VideoDownloader): The video + downloader handling checking/downloading this media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4439 on_progress_list_stop_now') + + # Check that, since the popup menu was created, the video downloader + # hasn't already finished checking/downloading the selected media + # data object + if not self.app_obj.download_manager_obj \ + or not worker_obj.running_flag \ + or worker_obj.download_item_obj.dbid != dbid \ + or worker_obj.video_downloader_obj is None: + # Do nothing + return + + # Stop the video downloader (causing the worker to be assigned a new + # downloads.DownloadItem, if there are any left) + video_downloader_obj.stop() + + + def on_progress_list_stop_soon(self, menu_item, dbid, worker_obj, + video_downloader_obj): + + """Called from a callback in self.progress_list_popup_menu(). + + Halts checking/downloading the selected media data object, after the + current video check/download has finished. + + Args: + + menu_item (Gtk.MenuItem): The menu item that was clicked + + dbid (int): The dbid of the selected media data object + + worker_obj (downloads.DownloadWorker): The worker currently + handling checking/downloading this media data object + + video_downloader_obj (downloads.VideoDownloader): The video + downloader handling checking/downloading this media data object + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4439 on_progress_list_stop_soon') + + # Check that, since the popup menu was created, the video downloader + # hasn't already finished checking/downloading the selected media + # data object + if not self.app_obj.download_manager_obj \ + or not worker_obj.running_flag \ + or worker_obj.download_item_obj.dbid != dbid \ + or worker_obj.video_downloader_obj is None: + # Do nothing + return + + # Tell the video downloader to stop after the current video check/ + # download has finished + video_downloader_obj.stop_soon() + + + def on_results_list_right_click(self, treeview, event): + + """Called from callback in self.setup_progress_tab(). + + When the user right-clicks an item in the Results List, create a + context-sensitive popup menu. + + Args: + + treeview (Gtk.TreeView): The Results List's treeview + + event (Gdk.EventButton): The event emitting the Gtk signal + + """ + + if DEBUG_FUNC_FLAG: + print('mw 3713 on_results_list_right_click') + + if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: + + # If the user right-clicked on empty space, the call to + # .get_path_at_pos returns None (or an empty list) + if not treeview.get_path_at_pos( + int(event.x), + int(event.y), + ): + return + + path, column, cellx, celly = treeview.get_path_at_pos( + int(event.x), + int(event.y), + ) + + iter = self.results_list_liststore.get_iter(path) + if iter is not None: + self.results_list_popup_menu( + event, + path, + self.results_list_liststore[iter][0], + ) + + + def on_results_list_delete_video(self, menu_item, media_data_obj, path): + + """Called from a callback in self.video_catalogue_popup_menu() and + self.results_list_popup_menu(). + + Deletes the video, and removes a row from the Results List. + + Args: + + menu_item (Gtk.MenuItem): The menu item that was clicked + + media_data_obj (media.Video): The video displayed on the clicked + row + + path (Gtk.TreePath): Path to the clicked row in the treeview + + """ + + if DEBUG_FUNC_FLAG: + print('mw 4439 on_results_list_delete_video') + + # Delete the video + self.app_obj.delete_video(media_data_obj, True) + + # Remove the row from the Results List + iter = self.results_list_liststore.get_iter(path) + self.results_list_liststore.remove(iter) + + def on_spinbutton_changed(self, spinbutton): """Called from callback in self.setup_progress_tab(). @@ -6282,9 +6969,14 @@ class SimpleCatalogueItem(object): # Set the download status if self.video_obj.dl_flag: - self.status_image.set_from_pixbuf( - self.main_win_obj.pixbuf_dict['have_file_small'], - ) + if self.video_obj.archive_flag: + self.status_image.set_from_pixbuf( + self.main_win_obj.pixbuf_dict['archived_small'], + ) + else: + self.status_image.set_from_pixbuf( + self.main_win_obj.pixbuf_dict['have_file_small'], + ) else: self.status_image.set_from_pixbuf( self.main_win_obj.pixbuf_dict['no_file_small'], @@ -6776,9 +7468,14 @@ class ComplexCatalogueItem(object): # Set the download status if self.video_obj.dl_flag: - self.status_image.set_from_pixbuf( - self.main_win_obj.pixbuf_dict['have_file_small'], - ) + if self.video_obj.archive_flag: + self.status_image.set_from_pixbuf( + self.main_win_obj.pixbuf_dict['archived_small'], + ) + else: + self.status_image.set_from_pixbuf( + self.main_win_obj.pixbuf_dict['have_file_small'], + ) else: self.status_image.set_from_pixbuf( self.main_win_obj.pixbuf_dict['no_file_small'], @@ -7261,6 +7958,7 @@ class AddVideoDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label('Copy and paste the links to one or more videos') @@ -7323,11 +8021,7 @@ class AddVideoDialogue(Gtk.Dialog): image = Gtk.Image() box.add(image) - image.set_from_pixbuf( - main_win_obj.app_obj.file_manager_obj.load_to_pixbuf( - main_win_obj.icon_dict['folder_small'] - ), - ) + image.set_from_pixbuf(main_win_obj.pixbuf_dict['folder_small']) listmodel = Gtk.ListStore(str) for item in self.folder_list: @@ -7415,6 +8109,7 @@ class AddChannelDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label('Enter the channel name') @@ -7490,11 +8185,7 @@ class AddChannelDialogue(Gtk.Dialog): image = Gtk.Image() box.add(image) - image.set_from_pixbuf( - main_win_obj.app_obj.file_manager_obj.load_to_pixbuf( - main_win_obj.icon_dict['folder_small'] - ), - ) + image.set_from_pixbuf(main_win_obj.pixbuf_dict['folder_small']) listmodel = Gtk.ListStore(str) for item in self.folder_list: @@ -7580,6 +8271,7 @@ class AddPlaylistDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label('Enter the playlist name') @@ -7654,12 +8346,8 @@ class AddPlaylistDialogue(Gtk.Dialog): image = Gtk.Image() box.add(image) - image.set_from_pixbuf( - main_win_obj.app_obj.file_manager_obj.load_to_pixbuf( - main_win_obj.icon_dict['folder_small'] - ), - ) - + image.set_from_pixbuf(main_win_obj.pixbuf_dict['folder_small']) + listmodel = Gtk.ListStore(str) for item in self.folder_list: listmodel.append([item]) @@ -7744,6 +8432,7 @@ class AddFolderDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label('Enter the folder name') @@ -7799,12 +8488,8 @@ class AddFolderDialogue(Gtk.Dialog): image = Gtk.Image() box.add(image) - image.set_from_pixbuf( - main_win_obj.app_obj.file_manager_obj.load_to_pixbuf( - main_win_obj.icon_dict['folder_small'] - ), - ) - + image.set_from_pixbuf(main_win_obj.pixbuf_dict['folder_small']) + self.folder_list.sort() listmodel = Gtk.ListStore(str) @@ -7918,6 +8603,7 @@ class DeleteContainerDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) if not total_count: @@ -8067,6 +8753,7 @@ class SetDirectoryDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) if os.name == 'nt': @@ -8137,6 +8824,7 @@ class ExportDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) if not whole_flag: @@ -8268,6 +8956,7 @@ class ImportDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label('Choose which items to import') @@ -8364,7 +9053,7 @@ class ImportDialogue(Gtk.Dialog): # structure) # - 'import_flag': bool (True if this channel/playlist/folder should # be imported, False if not) - converted_list = self.convert_to_list(db_dict) + converted_list = self.convert_to_list( db_dict, [] ) # Add a line to the textview for each channel, playlist and folder for mini_dict in converted_list: @@ -8399,7 +9088,7 @@ class ImportDialogue(Gtk.Dialog): # Public class methods - def convert_to_list(self, db_dict, converted_list=[], + def convert_to_list(self, db_dict, converted_list, parent_mini_dict=None, recursion_level=0): """Called by self.__init__(), and then recursively by this function. @@ -8588,6 +9277,7 @@ class SetNicknameDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) if isinstance(media_data_obj, media.Channel): @@ -8614,6 +9304,248 @@ class SetNicknameDialogue(Gtk.Dialog): self.show_all() +class SetDestinationDialogue(Gtk.Dialog): + + """Python class handling a dialogue window that prompts the user to set the + alternative download destination for a channel, playlist or folder. + + Args: + + main_win_obj (mainwin.MainWin): The parent main window + + media_data_obj (media.Channel, media.Playlist, media.Folder): The media + data object whose download destination is to be changed + + """ + + + # Standard class methods + + + def __init__(self, main_win_obj, media_data_obj): + + if DEBUG_FUNC_FLAG: + print('mw 8711 __init__') + + Gtk.Dialog.__init__( + self, + 'Set download destination', + main_win_obj, + Gtk.DialogFlags.DESTROY_WITH_PARENT, + ( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK, + ) + ) + + self.set_modal(False) + + # Set up the dialogue window + box = self.get_content_area() + + grid = Gtk.Grid() + box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) + grid.set_row_spacing(main_win_obj.spacing_size) + + if os.name == 'nt': + dir_name = 'folder' + else: + dir_name = 'directory' + + if isinstance(media_data_obj, media.Channel): + obj_type = 'channel' + elif isinstance(media_data_obj, media.Playlist): + obj_type = 'playlist' + else: + obj_type = 'folder' + + label = Gtk.Label( + 'This ' + obj_type + ' can store its videos in its own ' \ + + dir_name + ', or it can store\nthem in a different ' \ + + dir_name \ + + '\n\nChoose a different ' + dir_name + ' if:' \ + + '\n\n1. You want to add a channel and its playlists, without' \ + + ' downloading\nthe same video twice' \ + + '\n\n2. A video creator has channels on both YouTube and' \ + + ' BitChute, and\nyou want to add both without downloading the' \ + + ' same video twice', + ) + grid.attach(label, 0, 0, 1, 1) + + separator = Gtk.HSeparator() + grid.attach(separator, 0, 1, 1, 1) + + radiobutton = Gtk.RadioButton.new_with_label_from_widget( + None, + 'Use this ' + obj_type + '\'s own ' + dir_name, + ) + grid.attach(radiobutton, 0, 2, 1, 1) + # Signal connect appears below + + radiobutton2 = Gtk.RadioButton.new_from_widget(radiobutton) + radiobutton2.set_label('Choose a different ' + dir_name + ':') + grid.attach(radiobutton2, 0, 3, 1, 1) + # Signal connect appears below + + # Get a sorted list of channels/playlists/folders + app_obj = main_win_obj.app_obj + name_list = list(app_obj.media_name_dict.keys()) + name_list.sort(key=lambda x: x.lower()) + + # Add a combo + store = Gtk.ListStore(GdkPixbuf.Pixbuf, str) + + count = -1 + select_index = 0 + + for name in name_list: + dbid = app_obj.media_name_dict[name] + obj = app_obj.media_reg_dict[dbid] + + if isinstance(obj, media.Channel): + icon_name = 'channel_small' + elif isinstance(obj, media.Playlist): + icon_name = 'playlist_small' + else: + icon_name = 'folder_small' + + store.append( [main_win_obj.pixbuf_dict[icon_name], name] ) + + count += 1 + if dbid == media_data_obj.master_dbid: + select_index = count + + combo = Gtk.ComboBox.new_with_model(store) + grid.attach(combo, 0, 4, 1, 1) + combo.set_hexpand(True) + + renderer_pixbuf = Gtk.CellRendererPixbuf() + combo.pack_start(renderer_pixbuf, False) + combo.add_attribute(renderer_pixbuf, 'pixbuf', 0) + + renderer_text = Gtk.CellRendererText() + combo.pack_start(renderer_text, True) + combo.add_attribute(renderer_text, 'text', 1) + + combo.set_active(select_index) + # Signal connect appears below + + if media_data_obj.master_dbid == media_data_obj.dbid: + combo.set_sensitive(False) + else: + radiobutton2.set_active(True) + combo.set_sensitive(True) + + # (Store function arguments as IVs, so callback functions can retrieve + # them) + self.main_win_obj = main_win_obj + self.media_data_obj = media_data_obj + # (Store the user's choice as an IV, so the calling function can + # retrieve it) + self.choice = media_data_obj.master_dbid + + # Signal connects from above + radiobutton.connect( + 'toggled', + self.on_radiobutton_toggled, + combo, + ) + + radiobutton2.connect( + 'toggled', + self.on_radiobutton2_toggled, + combo, + ) + + combo.connect('changed', self.on_combo_changed) + + # Display the dialogue window + self.show_all() + + + def on_combo_changed(self, combo): + + """Called from callback in self.__init__(). + + Store the combobox's selected item, so the calling function can + retrieve it. + + Args: + + combo (Gtk.ComboBox): The clicked widget + + """ + + if DEBUG_FUNC_FLAG: + print('mw 6014 on_combo_changed') + + tree_iter = combo.get_active_iter() + model = combo.get_model() + pixbuf, name = model[tree_iter][:2] + + # (Allow for the possibility that the media data object might have + # been deleted, since the dialogue window opened) + if name in self.main_win_obj.app_obj.media_name_dict: + dbid = self.main_win_obj.app_obj.media_name_dict[name] + obj = self.main_win_obj.app_obj.media_reg_dict[dbid] + self.choice = obj.dbid + + + def on_radiobutton_toggled(self, radiobutton, combo): + + """Called from callback in self.__init__(). + + When the specified radiobutton is toggled, modify other widgets in the + dialogue window, and set self.choice (the value to be retrieved by the + calling function) + + Args: + + radiobutton (Gtk.RadioButton): The clicked widget + + """ + + if DEBUG_FUNC_FLAG: + print('mw 7974 on_radiobutton_toggled') + + if radiobutton.get_active(): + combo.set_sensitive(False) + self.choice = self.media_data_obj.dbid + + + def on_radiobutton2_toggled(self, radiobutton2, combo): + + """Called from callback in self.__init__(). + + When the specified radiobutton is toggled, modify other widgets in the + dialogue window, and set self.choice (the value to be retrieved by the + calling function) + + Args: + + radiobutton2 (Gtk.RadioButton): The clicked widget + + """ + + if DEBUG_FUNC_FLAG: + print('mw 7974 on_radiobutton2_toggled') + + if radiobutton2.get_active(): + combo.set_sensitive(True) + + tree_iter = combo.get_active_iter() + model = combo.get_model() + pixbuf, name = model[tree_iter][:2] + + # (Allow for the possibility that the media data object might have + # been deleted, since the dialogue window opened) + if name in self.main_win_obj.app_obj.media_name_dict: + dbid = self.main_win_obj.app_obj.media_name_dict[name] + obj = self.main_win_obj.app_obj.media_reg_dict[dbid] + self.choice = obj.dbid + + class RenameContainerDialogue(Gtk.Dialog): """Python class handling a dialogue window that prompts the user to rename @@ -8662,6 +9594,7 @@ class RenameContainerDialogue(Gtk.Dialog): grid = Gtk.Grid() box.add(grid) + grid.set_border_width(main_win_obj.spacing_size) grid.set_row_spacing(main_win_obj.spacing_size) label = Gtk.Label( diff --git a/tartube/media.py b/tartube/media.py index 598d61e..c122be0 100755 --- a/tartube/media.py +++ b/tartube/media.py @@ -541,6 +541,14 @@ class GenericContainer(GenericMedia): # Set accessors + def set_dl_disable_flag(self, flag): + + if flag: + self.dl_disable_flag = True + else: + self.dl_disable_flag = False + + def reset_counts(self, vid_count, new_count, fav_count, dl_count): """Called by mainapp.TartubeApp.update_db(). @@ -588,6 +596,56 @@ class GenericContainer(GenericMedia): self.new_count -= 1 + def set_master_dbid(self, app_obj, dbid): + + if dbid == self.master_dbid: + # No change to the current value + return + + else: + + # Update the old alternative download destination + if self.master_dbid != self.dbid: + old_dest_obj = app_obj.media_reg_dict[self.master_dbid] + old_dest_obj.del_slave_dbid(self.dbid) + + # Update this object's IV + self.master_dbid = dbid + + if self.master_dbid != self.dbid: + + # Update the new alternative download destination + new_dest_obj = app_obj.media_reg_dict[self.master_dbid] + new_dest_obj.add_slave_dbid(self.dbid) + + + def add_slave_dbid(self, dbid): + + """Called by self.set_master_dbid() only.""" + + # (Failsafe: don't add the same value to self.slave_dbid_list) + match_flag = False + for slave_dbid in self.slave_dbid_list: + if slave_dbid == dbid: + match_flag = True + break + + if not match_flag: + self.slave_dbid_list.append(dbid) + + + def del_slave_dbid(self, dbid): + + """Called by self.set_master_dbid() only.""" + new_list = [] + + for slave_dbid in self.slave_dbid_list: + if slave_dbid != dbid: + new_list.append(slave_dbid) + + self.slave_dbid_list = new_list.copy() + + def set_name(self, name): """Must only be called by mainapp.TartubeApp.rename_container().""" @@ -905,6 +963,9 @@ class Video(GenericMedia): # it's marked as a favourite if the same IV in the parent channel, # playlist or folder (also in the parent's parent, and so on) is True self.fav_flag = False + # Flag set to True if the video is archived, meaning that it can't be + # auto-deleted (but it can still be deleted manually by the user) + self.archive_flag = False # The file's directory, name and extension self.file_dir = None @@ -1019,6 +1080,13 @@ class Video(GenericMedia): # Set accessors + def set_archive_flag(self, flag): + + if flag: + self.archive_flag = True + else: + self.archive_flag = False + def set_dl_flag(self, flag=False): @@ -1303,10 +1371,27 @@ class Channel(GenericRemoteContainer): # Download source (a URL) self.source = None + # Alternative download destination - the dbid of a channel, playlist or + # folder in whose directory videos, thumbnails (etc) are downloaded. + # By default, set to the dbid of this channel; but can be set to the + # dbid of any other channel/playlist/folder + # Used for: (1) adding a channel and its playlists to the Tartube + # database, so that duplicate videos don't exist on the user's + # filesystem, (2) tying together, for example, a YouTube and a + # BitChute account, so that duplicate videos don't exist on the + # user's filesystem + self.master_dbid = dbid + # A list of dbids for any channel, playlist or folder that uses this + # channel as its alternative destination + self.slave_dbid_list = [] + # Flag set to True if Tartube should always simulate the download of # videos in this channel, or False if the downloads.DownloadManager # object should decide whether to simulate, or not self.dl_sim_flag = False + # Flag set to True if this channel should never be checked or + # downloaded + self.dl_disable_flag = False # Flag set to True if this channel is marked as favourite, meaning # that all child video objects are automatically marked as # favourites @@ -1440,10 +1525,27 @@ class Playlist(GenericRemoteContainer): # Download source (a URL) self.source = None + # Alternative download destination - the dbid of a channel, playlist or + # folder in whose directory videos, thumbnails (etc) are downloaded. + # By default, set to the dbid of this playlist; but can be set to the + # dbid of any other channel/playlist/folder + # Used for: (1) adding a channel and its playlists to the Tartube + # database, so that duplicate videos don't exist on the user's + # filesystem, (2) tying together, for example, a YouTube and a + # BitChute account, so that duplicate videos don't exist on the + # user's filesystem + self.master_dbid = dbid + # A list of dbids for any channel, playlist or folder that uses this + # playlist as its alternative destination + self.slave_dbid_list = [] + # Flag set to True if Tartube should always simulate the download of # videos in this playlist, or False if the downloads.DownloadManager # object should decide whether to simulate, or not self.dl_sim_flag = False + # Flag set to True if this playlist should never be checked or + # downloaded + self.dl_disable_flag = False # Flag set to True if this playlist is marked as favourite, meaning # that all child video objects are automatically marked as # favourites @@ -1592,6 +1694,21 @@ class Folder(GenericContainer): # folder can't be changed self.nickname = name + # Alternative download destination - the dbid of a channel, playlist or + # folder in whose directory videos, thumbnails (etc) are downloaded. + # By default, set to the dbid of this folder; but can be set to the + # dbid of any other channel/playlist/folder + # Used for: (1) adding a channel and its playlists to the Tartube + # database, so that duplicate videos don't exist on the user's + # filesystem, (2) tying together, for example, a YouTube and a + # BitChute account, so that duplicate videos don't exist on the + # user's filesystem + # NB Fixed folders cannot have an alternative download destination + self.master_dbid = dbid + # A list of dbids for any channel, playlist or folder that uses this + # folder as its alternative destination + self.slave_dbid_list = [] + # Flag set to False if the folder can be deleted by the user, or True # if it can't be deleted by the user self.fixed_flag = fixed_flag @@ -1611,6 +1728,10 @@ class Folder(GenericContainer): # videos in this folder, or False if the downloads.DownloadManager # object should decide whether to simulate, or not self.dl_sim_flag = False + # Flag set to True if this folder should never be checked or + # downloaded. If True, the setting applies to any descendant + # channels, playlists and folders + self.dl_disable_flag = False # Flag set to True if this folder is hidden (not visible in the Video # Index). Note that only folders can be hidden; channels and # playlists cannot diff --git a/tartube/options.py b/tartube/options.py index fd8a296..de3987b 100755 --- a/tartube/options.py +++ b/tartube/options.py @@ -268,6 +268,13 @@ class OptionsManager(object): temporary directories. The same applies to the JSON and thumbnail files. + + use_fixed_folder (str or None): If not None, then all videos are + downloaded to one of Tartube's fixed folders (not including private + folders) - currently, that group consists of only 'Temporary + Videos' and 'Unsorted Videos'. The value should match the name of + the folder + """ @@ -371,6 +378,7 @@ class OptionsManager(object): 'sim_keep_description': False, 'sim_keep_info': False, 'sim_keep_thumbnail': True, + 'use_fixed_folder': None, } @@ -518,6 +526,7 @@ class OptionsParser(object): # OptionHolder('sim_keep_description', '', False), # OptionHolder('sim_keep_info', '', False), # OptionHolder('sim_keep_thumbnail', '', False), +# OptionHolder('use_fixed_folder', '', None), ] @@ -721,16 +730,31 @@ class OptionsParser(object): """ # Set the directory in which any downloaded videos will be saved + app_obj = self.download_manager_obj.app_obj media_data_obj = download_item_obj.media_data_obj - if isinstance(media_data_obj, media.Video): - save_path = media_data_obj.parent_obj.get_dir( - self.download_manager_obj.app_obj - ) + override_name = copy_dict['use_fixed_folder'] + if not isinstance(media_data_obj, media.Video) \ + and override_name is not None \ + and override_name in app_obj.media_name_dict: + + # Because of the override, save all videos to a fixed folder + other_dbid = app_obj.media_name_dict[override_name] + other_obj = app_obj.media_reg_dict[other_dbid] + save_path = other_obj.get_dir(app_obj) + else: - save_path = media_data_obj.get_dir( - self.download_manager_obj.app_obj - ) + + if isinstance(media_data_obj, media.Video): + save_path = media_data_obj.parent_obj.get_dir( + self.download_manager_obj.app_obj + ) + + else: + save_path = media_data_obj.get_dir( + self.download_manager_obj.app_obj + ) + # Set the youtube-dl output template for the video's file template = formats.FILE_OUTPUT_CONVERT_DICT[copy_dict['output_format']] diff --git a/tartube/tartube b/tartube/tartube index 587350e..2409da2 100755 --- a/tartube/tartube +++ b/tartube/tartube @@ -35,8 +35,8 @@ import mainapp # 'Global' variables __packagename__ = 'tartube' -__version__ = '1.1.015' -__date__ = '22 Aug 2019' +__version__ = '1.1.050' +__date__ = '26 Aug 2019' __copyright__ = 'Copyright \xa9 2019 A S Lewis' __license__ = """ Copyright \xc2\xa9 2019 A S Lewis. @@ -64,7 +64,7 @@ __app_id__ = 'io.sourceforge.tartube' # This is the default executable, in which youtube-dl updates are enabled. For # Debian packaging, use the 'tartube_debian' executable (see the comments in # setup.py) -__disable_ytdl_update_flag__ = False +__debian_install_flag__ = False # Tartube's icons are stored in the ../icons directory. If packagers want to # move them somewhere else, then adding the directory path to this list will # tell mainwin.MainWin.setup_pixbufs() how to find them diff --git a/tartube/tartube_debian b/tartube/tartube_debian index 25bb198..79b40ea 100755 --- a/tartube/tartube_debian +++ b/tartube/tartube_debian @@ -35,8 +35,8 @@ import mainapp # 'Global' variables __packagename__ = 'tartube' -__version__ = '1.1.015' -__date__ = '22 Aug 2019' +__version__ = '1.1.050' +__date__ = '26 Aug 2019' __copyright__ = 'Copyright \xa9 2019 A S Lewis' __license__ = """ Copyright \xc2\xa9 2019 A S Lewis. @@ -64,7 +64,7 @@ __app_id__ = 'io.sourceforge.tartube' # This is a modified executable, in which youtube-dl updates are disabled # (to meet the demands of Debian packaging). All other users should run the # 'tartube' executable (see the comments in setup.py) -__disable_ytdl_update_flag__ = True +__debian_install_flag__ = True # Tartube's icons are stored in the ../icons directory. If packagers want to # move them somewhere else, then adding the directory path to this list will # tell mainwin.MainWin.setup_pixbufs() how to find them