diff --git a/CHANGES b/CHANGES index 5261730..f58515d 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,70 @@ +v1.3.077 (26 Jan 2019) +------------------------------------------------------------------------------- + +MAJOR NEW FEATURES +- Drag and drop (for example, from a web browser into Tartube's main window) + is now fully working on Linux/BSD. On MS Windows, drag and drop does not + work at all for any Gtk application. It is unlikely that the Tartube + authors can do anything about this (Git #35) +- The 'Add new video(s)' dialogue window can now handle URLs representing + channels and playlists, as well as URLs representing individual videos. + During a download operation, if Tartube is expecting an individual video + but receives a channel/playlist, it will automatically create a new + channel, and download videos into that channel. You can change this default + behaviour, if you want (Edit > System preferences... > URL flexibility + preferences) +- To change the name of the new channel/playlist, right-click it and select + 'Filesystem > Rename default location...' +- If Tartube creates a channel, which should really be a playlist, then you + can now convert one to the other. Right-click a channel and select + 'Channel actions > Convert to playlist'. Right-click a playlist and select + 'Playlist actions > Convert to channel' +- In the download options windows, it's now very easy to tell Tartube to + convert videos to sound files. Open the window by clicking 'Edit > + General download options...', click the 'Hide advanced download options' + button if necessary, click the 'Sound only' tab, select your preferences, + and apply them by clicking the OK button at the bottom of the window +- You can now see the download options applied to a video, channel, playlist + or folder without having to download anything. Right-click a video/channel/ + playlist/folder and select 'Downloads > Show system command' +- During a download operation, the system commands used are now visible (by + default) in the Output Tab. The system command can also be displayed in the + terminal, if required; this is disabled by default + +MINOR NEW FEATURES +- In the Output Tab, the summary page is now hidden by default. To make it + visible, click 'Edit > System Preferences... > Output > + Show a summary of active threads' and then restart Tartube +- In the Errors/Warnings Tab, added checkbuttons to filter out errors and/or + warning messages, if required (Git #50) +- In the Progress tab, in the top half of the window, you can now right-click + an unnamed video to open it in your web browser. This will be useful in + identifying videos that did not download, and whose name is unknown to + Tartube (Git #51) +- Columns in the Progress tab have been rearranged a little, so that the + user can more easily see how quickly the download is progressing, when + Tartube's main window is small + +MAJOR FIXES +- Fixed multiple issues with Tartube, when running under Python 3.8 +- Replaced all remaining references to the Python os.rename() function, which + can cause crashes on some filesystems (Git #34) +- Fixed crashes caused by the new YouTube error messages (January 2020), which + some versions of youtube-dl cannot handle correctly +- Fixed issues with the default location for videos, again. Fixed an issue + with adding folders inside the currently selected folder (Git #36, #46) + +MINOR FIXES +- Fixed various Gtk warning messages, visible only on some systems +- Videos whose name contains an ampersand (&) character could not be opened by + clicking the 'Media player' label in the Video Catalogue. Fixed +- The properties windows for videos, channels and playlists showed a folder + icon, instead of a video/channel/playlist icon. Fixed +- The popup menu in the Progress tab, in the top half of the tab, did not work + as intended during a download operation, and again after a download + operation. Fixed both sets of issues +- Coloured text was not displayed in the Output Tab correctly. Fixed + v1.3.048 (23 Jan 2019) ------------------------------------------------------------------------------- diff --git a/README.rst b/README.rst index c9b7ec1..84915bc 100644 --- a/README.rst +++ b/README.rst @@ -15,11 +15,9 @@ Works with YouTube, BitChute, and hundreds of other websites * `5 Installation`_ * `6 Getting started`_ * `7. Frequently-Asked Questions`_ -* `8. Future plans`_ -* `9. Known issues`_ -* `10. Contributing`_ -* `11. Authors`_ -* `12. License`_ +* `8. Contributing`_ +* `9. Authors`_ +* `10. License`_ 1 Introduction ============== @@ -79,11 +77,11 @@ Problems can be reported at `our GitHub page `__ from Sourceforge -- `MS Windows (64-bit) installer `__ from Sourceforge -- `Source code `__ from Sourceforge +- `MS Windows (32-bit) installer `__ from Sourceforge +- `MS Windows (64-bit) installer `__ from Sourceforge +- `Source code `__ from Sourceforge - `Source code `__ and `support `__ from GitHub 5 Installation @@ -270,12 +268,12 @@ Videos saved to the **Temporary Videos** folder are deleted when **Tartube** shu 6.6 Adding videos ----------------- -You can add individual videos by clicking the **'Videos'** button near the top of the window. A popup window will appear. +You can add individual videos by clicking the **'Videos'** button near the top of the window. A dialogue window will appear. .. image:: screenshots/example4.png :alt: Adding videos -Copy and paste the video's URL into the popup window. You can copy and paste as many URLs as you like. +Copy and paste the video's URL into the dialogue window. You can copy and paste as many URLs as you like. When you're finished, click the **OK** button. @@ -294,9 +292,29 @@ You can also add a whole channel by clicking the **'Channel'** button or a whole .. image:: screenshots/example6.png :alt: Adding a channel -Copy and paste the channel's URL into the popup window. You should also give the channel a name. The channel's name is usually the name used on the website (but you can choose any name you like). +Copy and paste the channel's URL into the dialogue window. You should also give the channel a name. The channel's name is usually the name used on the website (but you can choose any name you like). -6.8 Adding folders +6.8 Adding videos, channels and playlists together +-------------------------------------------------- + +When adding a long list of URLs, containing a mixture of channels, playlists and individual videos, it's quicker to add them all at the same time. Click the **'Videos'** button near the top of the window, and paste all the links into the dialogue window. + +**Tartube** doesn't know anything about these links until you actually download them (or check them). If it's expecting an individual video, but receives a channel or a playlist, **Tartube** will the handle the conversion for you. + +By default, **Tartube** converts a link into a channel, when necessary. You can change this behaviour, if you want to. + +- In **Tartube**'s main window, click **Edit > System preferences... > Operations** +- Select one of the buttons listed under **URL flexibility preferences** + +Unfortunately, there is no way for **Tartube** to distinguish a channel from a playlist. Most video websites don't supply that information. + +If your list of URLs contains a mixture of channels and playlists, you can convert one to the other after the download has finished. + +- In **Tartube**'s main window, right-click a channel, and select **Channel actions > Convert to playlist** +- Alternatively, right-click a playlist, and select **Channel actions > Convert to channel** +- After converting, you can set a name for the new channel/playlist by right-clicking it, and selecting **Filesystem > Rename default location...** + +6.9 Adding folders ------------------ The left-hand side of the window will quickly still filling up. It's a good idea to create some folders, and to store your channels/playlists inside those folders. @@ -311,7 +329,7 @@ Then repeat that process to create a folder called **Music**. You can then drag- .. image:: screenshots/example8.png :alt: A channel inside a folder -6.9 Things you can do +6.10 Things you can do ---------------------- Once you've finished adding videos, channels, playlists and folders, there are basically four things **Tartube** can do: @@ -331,7 +349,7 @@ To **Check** or **Download** videos, channels and playlists, use the buttons nea **Protip:** Do a **'Check'** operation before you do **'Refresh'** operation -6.10 General download options +6.11 General download options ----------------------------- **youtube-dl** offers a large number of download options. This is how to set them. @@ -343,7 +361,7 @@ To **Check** or **Download** videos, channels and playlists, use the buttons nea A new window opens. Any changes you make in this window aren't actually applied until you click the **'Apply'** or **'OK'** buttons. -6.11 Other download options +6.12 Other download options --------------------------- Those are the *default* download options. If you want to apply a *different* set of download options to a particular channel or particular playlist, you can do so. @@ -372,7 +390,7 @@ The previous set of download options still applies to everything in the **Music* .. image:: screenshots/example13.png :alt: Download options applied to the Village People channel -6.12 Favourite videos +6.13 Favourite videos --------------------- You can mark channels, playlists and even whole folders as favourites. @@ -382,7 +400,7 @@ You can mark channels, playlists and even whole folders as favourites. When you do that, any videos you download will appear in the **Favourite Videos** folder (as well as in their normal location). -6.13 Watching videos +6.14 Watching videos -------------------- If you've downloaded a video, you can watch it by clicking the word **Player**. @@ -394,7 +412,7 @@ If you haven't downloaded the video yet, you can watch it online by clicking the If it's a YouTube video that is restricted (not available in certain regions, or without confirming your age), it's often possible to watch the same video without restrictions on the **HookTube** website. -6.14 Combining channels, playlists and folders +6.15 Combining channels, playlists and folders ---------------------------------------------- **Tartube** can download videos from several channels and/or playlists into a single directory (folder) on your computer's hard drive. There are three situations in which this might be useful: @@ -403,7 +421,7 @@ If it's a YouTube video that is restricted (not available in certain regions, or - A creator releases their videos on **BitChute** as well as on **YouTube**. You have added both channels, but you don't want to download duplicate videos - You don't care about keeping videos in separate directories/folders on your filesystem. You just want to download all videos to one place -6.14.1 Combining one channel and many playlists +6.15.1 Combining one channel and many playlists ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A creator might have a single channel, and several playlists. The playlists contain videos from that channel (but not necessarily *every* video). @@ -417,7 +435,7 @@ The solution is to tell **Tartube** to store all the videos from the channel and - Now, right-click on each playlist in turn and select **Playlist actions > Set download destination...** - In the dialogue window, click **Choose a different directory/folder**, select the name of the channel, then click the **OK button** -6.14.2 Combining channels from different websites +6.15.2 Combining channels from different websites ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A creator might release their videos on **YouTube**, but also on a site like **BitChute**. Sometimes they will only release a particular video on **BitChute**. @@ -433,7 +451,7 @@ The solution is to tell **Tartube** to store videos from both channels in a sing It doesn't matter which of the two channels you use as the download destination. There is also no limit to the number of parallel channels, so if a creator uploads videos to a dozen different websites, you can add them all. -6.14.3 Download all videos to a single folder +6.15.3 Download all videos to a single folder ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you don't care about keeping videos in separate directories/folders on your filesystem, you can download *all* videos into the **Unsorted videos** folder. Regardless of whether you have added one channel or a thousand, all the videos will be stored in that one place. @@ -444,7 +462,7 @@ If you don't care about keeping videos in separate directories/folders on your f Alternatively, you could select **Temporary Videos**. If you do, videos will be deleted when you shut down **Tartube** (and will not be re-downloaded in the future). -6.15 Archiving videos +6.16 Archiving videos --------------------- You can tell **Tartube** to automatically delete videos after some period of time. This is useful if you don't have an infinitely large hard drive. @@ -463,7 +481,7 @@ You can also archive all the videos in a channel, playlist or folder. - This action applies to *all* videos that are *currently* in the folder, including the contents of any channels and playlists in that folder - It doesn't apply to any videos you might download in the future -6.16 Exporting/importing the Tartube database +6.17 Exporting/importing the Tartube database --------------------------------------------- You can export the contents of **Tartube**'s database and, at any time in the future, import that information into a different **Tartube** database, perhaps on a different computer. @@ -483,7 +501,7 @@ This is how to import the data into a different **Tartube** database. - Select the export file you created earlier - A dialogue window will appear. You can choose how much of the database you want to import -6.17 Importing videos from other applications +6.18 Importing videos from other applications --------------------------------------------- **Tartube** is a GUI front-end for `youtube-dl `__, but it is not the only one. If you've downloaded videos using another application, this is how to add them to Tartube's database. @@ -494,7 +512,7 @@ This is how to import the data into a different **Tartube** database. - In the **Tartube** menu, click **Operations > Refresh database**. **Tartube** will search for video files, and try to match them with the contents of its database - The whole process might some time, so be patient -6.18 Converting to audio +6.19 Converting to audio ------------------------ **Tartube** can automatically extract the audio from its downloaded videos, if that's what you want. @@ -504,9 +522,18 @@ The first step is to make sure that either FFmpeg or AVconv is installed on your The remaining steps are simple: - In **Tartube**'s main window, click **Edit > General download options...** -- In the new window, if the **Post-processing** tab is not visible, then click the button **Show advanced download options** -- Now click on the **Post-processing** tab -- Click the button **Post-process video files to convert them to audio-only files** to select it + +In the new window, if the **Post-processing** tab is not visible, do this: + +- Click the **Sound Only** tab +- Select the checkbox **Download each video, extract the sound, and then discard the original videos** +- In the boxes below, select an audio format and an audio quality +- Click the **OK** button at the bottom of the window to apply your changes + +If the **Post-processing** tab *is* visible, do this: + +- Click on the **Post-processing** tab +- Select the checkbox **Post-process video files to convert them to audio-only files** - If you want, click the button **Keep video file after post-processing it** to select it - In the box labelled **Audio format of the post-processed file**, specify what type of audio file you want - **.mp3**, **.wav**, etc - Click the **OK** button at the bottom of the window to apply your changes @@ -535,7 +562,7 @@ Note that Tartube does not create backup copies of the videos you've downloaded. **Q: I want to convert the video files to audio files!** -A: See `6.18 Converting to audio`_ +A: See `6.19 Converting to audio`_ **Q: I want to see all the videos on a single page, not spread over several pages!** @@ -579,42 +606,18 @@ The NSIS scripts used to create the installers can be found here: The scripts contain full instructions, so you should be able to create your own installer, and compare it with the official one. -8. Future plans +8. Contributing =============== -- Fix the endless crashes **DONE** -- Support for multiple databases (so you can store videos on two external hard drives at the same time) -- Add download scheduling **DONE** -- Add video archiving **DONE** -- Allow selection of multiple videos in the catalogue, so the same action can be applied to all of them at the same time **DONE** -- Tie channels and playlists together, so that they won't both download the same video **DONE** -- Add tooltips for everything **DONE** -- Add more youtube-dl options **DONE** -- Expand this guide to explain all features of Tartube - -9. Known issues -=============== - -- Tartube crashes continuously and often **FIXED** -- Alphabetic sorting of channels/playlists/folders doesn't always work as intended, due to an unresolved Gtk issue **FIXED** -- Channels/playlists/folder selection does not always work as intended, due to an unresolved Gtk issue **FIXED** -- Users can type in comboboxes, but this should not be possible **FIXED** -- Some MS Windows users report that Tartube will install, but not run **FIXED** -- Some MS Windows users report that Tartube doesn't recognise FFmpeg **FIXED** -- Installation via **pip** does not work - -10. Contributing -================ - - Report a bug: Use the Github `issues `__ page -11. Authors -=========== +9. Authors +========== See the `AUTHORS `__ file. -12. License +10. License =========== Tartube is licensed under the `GNU General Public License v3.0 `__. diff --git a/nsis/tartube_install_32bit.nsi b/nsis/tartube_install_32bit.nsi index e1c6e64..8659969 100644 --- a/nsis/tartube_install_32bit.nsi +++ b/nsis/tartube_install_32bit.nsi @@ -1,4 +1,4 @@ -# Tartube v1.3.048 installer script for MS Windows +# Tartube v1.3.077 installer script for MS Windows # # Copyright (C) 2019-2020 A S Lewis # @@ -139,7 +139,7 @@ ;Name and file Name "Tartube" - OutFile "install-tartube-1.3.048-32bit.exe" + OutFile "install-tartube-1.3.077-32bit.exe" ;Default installation folder InstallDir "$LOCALAPPDATA\Tartube" @@ -244,7 +244,7 @@ Section "Tartube" SecClient "Publisher" "A S Lewis" WriteRegStr HKLM \ "Software\Microsoft\Windows\CurrentVersion\Uninstall\Tartube" \ - "DisplayVersion" "1.3.048" + "DisplayVersion" "1.3.077" # Create uninstaller WriteUninstaller "$INSTDIR\Uninstall.exe" diff --git a/nsis/tartube_install_64bit.nsi b/nsis/tartube_install_64bit.nsi index 83967f8..c5e9a62 100644 --- a/nsis/tartube_install_64bit.nsi +++ b/nsis/tartube_install_64bit.nsi @@ -1,4 +1,4 @@ -# Tartube v1.3.048 installer script for MS Windows +# Tartube v1.3.077 installer script for MS Windows # # Copyright (C) 2019-2020 A S Lewis # @@ -140,7 +140,7 @@ ;Name and file Name "Tartube" - OutFile "install-tartube-1.3.048-64bit.exe" + OutFile "install-tartube-1.3.077-64bit.exe" ;Default installation folder InstallDir "$LOCALAPPDATA\Tartube" @@ -245,7 +245,7 @@ Section "Tartube" SecClient "Publisher" "A S Lewis" WriteRegStr HKLM \ "Software\Microsoft\Windows\CurrentVersion\Uninstall\Tartube" \ - "DisplayVersion" "1.3.048" + "DisplayVersion" "1.3.077" # Create uninstaller WriteUninstaller "$INSTDIR\Uninstall.exe" diff --git a/screenshots/example4.png b/screenshots/example4.png index a5e09d8..37cd7a0 100644 Binary files a/screenshots/example4.png and b/screenshots/example4.png differ diff --git a/setup.py b/setup.py index b089a87..cac9f36 100755 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ if env_var_value is not None: # Setup setuptools.setup( name='tartube', - version='1.3.053', + version='1.3.077', 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 b6d3930..15295b9 100644 --- a/tartube/config.py +++ b/tartube/config.py @@ -1148,7 +1148,6 @@ class GenericEditWin(GenericConfigWin): 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): @@ -1190,8 +1189,14 @@ class GenericEditWin(GenericConfigWin): ) label2.set_hexpand(False) + parent_obj = self.edit_obj.parent_obj if parent_obj: - icon_path2 = main_win_obj.icon_dict['folder_small'] + if isinstance(parent_obj, media.Channel): + icon_path2 = main_win_obj.icon_dict['channel_small'] + elif isinstance(parent_obj, media.Playlist): + icon_path2 = main_win_obj.icon_dict['playlist_small'] + else: + icon_path2 = main_win_obj.icon_dict['folder_small'] else: icon_path2 = main_win_obj.icon_dict['folder_black_small'] @@ -1955,6 +1960,8 @@ class OptionsEditWin(GenericEditWin): self.setup_others_tab() if not self.app_obj.simple_options_flag: self.setup_advanced_tab() + else: + self.setup_sound_only_tab() def setup_general_tab(self): @@ -3374,6 +3381,70 @@ class OptionsEditWin(GenericEditWin): ) + def setup_sound_only_tab(self): + + """Called by self.setup_tabs(). + + Sets up the 'Sound Only' tab. + """ + + tab, grid = self.add_notebook_tab('_Sound only') + grid_width = 4 + + # Sound only options + self.add_label(grid, + 'Sound only options', + 0, 0, grid_width, 1, + ) + + # (The MS Windows installer includes FFmpeg) + text = 'Download each video, extract the sound, and then discard the' \ + + ' original videos' + if os.name != 'nt': + text += '\n(requires that FFmpeg or AVConv is installed on your' \ + + ' system)' + + self.add_checkbutton(grid, + text, + 'extract_audio', + 0, 1, grid_width, 1, + ) + + label = self.add_label(grid, + 'Use this audio format: ', + 0, 2, 1, 1, + ) + label.set_hexpand(False) + + combo_list = formats.AUDIO_FORMAT_LIST + combo_list.insert(0, '') + combo = self.add_combo(grid, + combo_list, + 'audio_format', + 1, 2, 1, 1, + ) + combo.set_hexpand(True) + + label2 = self.add_label(grid, + 'Use this audio quality: ', + 2, 2, 1, 1, + ) + label2.set_hexpand(False) + + combo2_list = [ + ['High', '0'], + ['Medium', '5'], + ['Low', '9'], + ] + + combo2 = self.add_combo_with_data(grid, + combo2_list, + 'audio_quality', + 3, 2, 1, 1, + ) + combo2.set_hexpand(True) + + # (Tab support functions) @@ -5345,89 +5416,143 @@ class SystemPrefWin(GenericPrefWin): checkbutton6.connect('toggled', self.on_reverse_button_toggled) checkbutton7 = self.add_checkbutton(grid, - 'Show system warning messages in the \'Errors/Warnings\' tab', - self.app_obj.system_warning_show_flag, - True, # Can be toggled by user - 0, 7, 1, 1, - ) - checkbutton7.connect('toggled', self.on_warning_button_toggled) - - checkbutton8 = self.add_checkbutton(grid, 'Don\'t remove number of system messages from tab label until' \ + ' \'Clear\' button is clicked', self.app_obj.system_msg_keep_totals_flag, True, # Can be toggled by user - 0, 8, 1, 1, + 0, 7, 1, 1, ) - checkbutton8.connect('toggled', self.on_system_keep_button_toggled) + checkbutton7.connect('toggled', self.on_system_keep_button_toggled) # System tray preferences self.add_label(grid, 'System tray preferences', - 0, 9, 1, 1, + 0, 8, 1, 1, ) - checkbutton9 = self.add_checkbutton(grid, + checkbutton8 = self.add_checkbutton(grid, 'Show icon in system tray', self.app_obj.show_status_icon_flag, True, # Can be toggled by user + 0, 9, 1, 1, + ) + checkbutton8.set_hexpand(False) + # signal connnect appears below + + checkbutton9 = self.add_checkbutton(grid, + 'Close to the tray, rather than closing the application', + self.app_obj.close_to_tray_flag, + True, # Can be toggled by user 0, 10, 1, 1, ) checkbutton9.set_hexpand(False) - # signal connnect appears below - - checkbutton10 = self.add_checkbutton(grid, - 'Close to the tray, rather than closing the application', - self.app_obj.close_to_tray_flag, - True, # Can be toggled by user - 0, 11, 1, 1, - ) - checkbutton10.set_hexpand(False) - checkbutton10.connect('toggled', self.on_close_to_tray_toggled) + checkbutton9.connect('toggled', self.on_close_to_tray_toggled) if not self.app_obj.show_status_icon_flag: - checkbutton10.set_sensitive(False) + checkbutton9.set_sensitive(False) # signal connect from above - checkbutton9.connect( + checkbutton8.connect( 'toggled', self.on_show_status_icon_toggled, - checkbutton10, + checkbutton9, ) # Dialogue window preferences self.add_label(grid, 'Dialogue window preferences', - 0, 13, 1, 1, + 0, 11, 1, 1, ) - checkbutton11 = self.add_checkbutton(grid, + checkbutton10 = self.add_checkbutton(grid, 'When adding channels/playlists, keep the dialogue window open', self.app_obj.dialogue_keep_open_flag, True, # Can be toggled by user - 0, 14, 1, 1, + 0, 12, 1, 1, ) - checkbutton11.set_hexpand(False) + checkbutton10.set_hexpand(False) # signal connnect appears below - checkbutton12 = self.add_checkbutton(grid, + checkbutton11 = self.add_checkbutton(grid, 'When adding videos/channels/playlists, copy URLs from the' \ + ' system clipboard', self.app_obj.dialogue_copy_clipboard_flag, True, # Can be toggled by user - 0, 15, 1, 1, + 0, 13, 1, 1, ) - checkbutton12.set_hexpand(False) - checkbutton12.connect('toggled', self.on_clipboard_button_toggled) + checkbutton11.set_hexpand(False) + checkbutton11.connect('toggled', self.on_clipboard_button_toggled) if self.app_obj.dialogue_keep_open_flag: - checkbutton12.set_sensitive(False) + checkbutton11.set_sensitive(False) # signal connect from above - checkbutton11.connect( + checkbutton10.connect( 'toggled', self.on_keep_open_button_toggled, - checkbutton12, + checkbutton11, ) + # Error/warning preferences + self.add_label(grid, + 'Error/warning preferences', + 0, 14, 1, 1, + ) + + checkbutton12 = self.add_checkbutton(grid, + 'Show system error messages in the \'Errors/Warnings\' tab', + self.app_obj.system_error_show_flag, + True, # Can be toggled by user + 0, 15, 1, 1, + ) + checkbutton12.connect('toggled', self.on_error_button_toggled) + + checkbutton13 = self.add_checkbutton(grid, + 'Show system warning messages in the \'Errors/Warnings\' tab', + self.app_obj.system_warning_show_flag, + True, # Can be toggled by user + 0, 16, 1, 1, + ) + checkbutton13.connect('toggled', self.on_warning_button_toggled) + + checkbutton14 = self.add_checkbutton(grid, + 'Ignore \'Requested formats are incompatible for merge\' warnings', + self.app_obj.ignore_merge_warning_flag, + True, # Can be toggled by user + 0, 17, 1, 1, + ) + checkbutton14.connect('toggled', self.on_merge_button_toggled) + + checkbutton15 = self.add_checkbutton(grid, + 'Ignore YouTube copyright errors', + self.app_obj.ignore_yt_copyright_flag, + True, # Can be toggled by user + 0, 18, 1, 1, + ) + checkbutton15.connect('toggled', self.on_copyright_button_toggled) + + checkbutton16 = self.add_checkbutton(grid, + 'Ignore \'Child process exited with non-zero code\' errors', + self.app_obj.ignore_child_process_exit_flag, + True, # Can be toggled by user + 0, 19, 1, 1, + ) + checkbutton16.connect('toggled', self.on_child_process_button_toggled) + + checkbutton17 = self.add_checkbutton(grid, + 'Ignore \'There are no annotations to write\' warnings', + self.app_obj.ignore_no_annotations_flag, + True, # Can be toggled by user + 0, 20, 1, 1, + ) + checkbutton17.connect('toggled', self.on_no_annotations_button_toggled) + + checkbutton18 = self.add_checkbutton(grid, + 'Ignore \'Video doesn\'t have subtitles\' warnings', + self.app_obj.ignore_no_subtitles_flag, + True, # Can be toggled by user + 0, 21, 1, 1, + ) + checkbutton18.connect('toggled', self.on_no_subtitles_button_toggled) + def setup_videos_tab(self): @@ -5799,17 +5924,86 @@ class SystemPrefWin(GenericPrefWin): 'default', ) + # URL flexibility preferences + self.add_label(grid, + 'URL flexibility preferences', + 0, 7, grid_width, 1, + ) + + radiobutton4 = self.add_radiobutton(grid, + None, + 'If a video\'s URL represents a channel/playlist, not a video,' \ + + ' don\'t download it', + 0, 8, grid_width, 1, + ) + # Signal connect appears below + + radiobutton5 = self.add_radiobutton(grid, + radiobutton4, +# 'If a URL represents a channel/playlist, not a video, download' \ +# + ' multiple videos', + '...or, download multiple videos into the containing folder', + 0, 9, grid_width, 1, + ) + if self.app_obj.operation_convert_mode == 'multi': + radiobutton5.set_active(True) + # Signal connect appears below + + radiobutton6 = self.add_radiobutton(grid, + radiobutton5, +# 'If a URL represents a channel/playlist, not a video, convert' \ +# + ' the video to a channel', + '...or, create a new channel, and download the videos into that', + 0, 10, grid_width, 1, + ) + if self.app_obj.operation_convert_mode == 'channel': + radiobutton6.set_active(True) + # Signal connect appears below + + radiobutton7 = self.add_radiobutton(grid, + radiobutton6, +# 'If a URL represents a channel/playlist, not a video, convert' \ +# + ' the video to a playlist', + '...or, create a new playlist, and download the videos into that', + 0, 11, grid_width, 1, + ) + if self.app_obj.operation_convert_mode == 'playlist': + radiobutton7.set_active(True) + # Signal connect appears below + + # Signal connects from above + radiobutton4.connect( + 'toggled', + self.on_convert_from_button_toggled, + 'disable', + ) + radiobutton5.connect( + 'toggled', + self.on_convert_from_button_toggled, + 'multi', + ) + radiobutton6.connect( + 'toggled', + self.on_convert_from_button_toggled, + 'channel', + ) + radiobutton7.connect( + 'toggled', + self.on_convert_from_button_toggled, + 'playlist', + ) + # Performance limits self.add_label(grid, 'Performance limits', - 0, 7, grid_width, 1, + 0, 12, grid_width, 1, ) checkbutton3 = self.add_checkbutton(grid, 'Limit simultaneous downloads to', self.app_obj.num_worker_apply_flag, True, # Can be toggled by user - 0, 8, 1, 1, + 0, 13, 1, 1, ) checkbutton3.set_hexpand(False) checkbutton3.connect('toggled', self.on_worker_button_toggled) @@ -5819,7 +6013,7 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.num_worker_max, 1, # Step self.app_obj.num_worker_default, - 1, 8, 1, 1, + 1, 13, 1, 1, ) spinbutton.connect('value-changed', self.on_worker_spinbutton_changed) @@ -5827,7 +6021,7 @@ class SystemPrefWin(GenericPrefWin): 'Limit download speed to', self.app_obj.bandwidth_apply_flag, True, # Can be toggled by user - 0, 9, 1, 1, + 0, 14, 1, 1, ) checkbutton4.set_hexpand(False) checkbutton4.connect('toggled', self.on_bandwidth_button_toggled) @@ -5837,7 +6031,7 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.bandwidth_max, 1, # Step self.app_obj.bandwidth_default, - 1, 9, 1, 1, + 1, 14, 1, 1, ) spinbutton2.connect( 'value-changed', @@ -5846,14 +6040,14 @@ class SystemPrefWin(GenericPrefWin): self.add_label(grid, 'KiB/s', - 2, 9, 1, 1, + 2, 14, 1, 1, ) checkbutton5 = self.add_checkbutton(grid, 'Limit video resolution (overriding video format options) to', self.app_obj.video_res_apply_flag, True, # Can be toggled by user - 0, 10, 1, 1, + 0, 15, 1, 1, ) checkbutton5.set_hexpand(False) checkbutton5.connect('toggled', self.on_video_res_button_toggled) @@ -5861,7 +6055,7 @@ class SystemPrefWin(GenericPrefWin): combo = self.add_combo(grid, formats.VIDEO_RESOLUTION_LIST, None, - 1, 10, 1, 1, + 1, 15, 1, 1, ) combo.set_active( formats.VIDEO_RESOLUTION_LIST.index( @@ -5873,7 +6067,7 @@ class SystemPrefWin(GenericPrefWin): # Time-saving preferences self.add_label(grid, 'Time-saving preferences', - 0, 11, grid_width, 1, + 0, 16, grid_width, 1, ) checkbutton6 = self.add_checkbutton(grid, @@ -5881,20 +6075,20 @@ class SystemPrefWin(GenericPrefWin): + ' sending videos we already have', self.app_obj.operation_limit_flag, True, # Can be toggled by user - 0, 12, grid_width, 1, + 0, 17, grid_width, 1, ) checkbutton6.set_hexpand(False) # Signal connect appears below self.add_label(grid, 'Stop after this many videos (when checking)', - 0, 13, 1, 1, + 0, 18, 1, 1, ) entry = self.add_entry(grid, self.app_obj.operation_check_limit, True, - 1, 13, 1, 1, + 1, 18, 1, 1, ) entry.set_hexpand(False) entry.set_width_chars(4) @@ -5904,13 +6098,13 @@ class SystemPrefWin(GenericPrefWin): self.add_label(grid, 'Stop after this many videos (when downloading)', - 0, 14, 1, 1, + 0, 19, 1, 1, ) entry2 = self.add_entry(grid, self.app_obj.operation_download_limit, True, - 1, 14, 1, 1, + 1, 19, 1, 1, ) entry2.set_hexpand(False) entry2.set_width_chars(4) @@ -5929,7 +6123,7 @@ class SystemPrefWin(GenericPrefWin): # Download options preferences self.add_label(grid, 'Download options preferences', - 0, 15, grid_width, 1, + 0, 20, grid_width, 1, ) checkbutton7 = self.add_checkbutton(grid, @@ -5937,7 +6131,7 @@ class SystemPrefWin(GenericPrefWin): + ' download options', self.app_obj.auto_clone_options_flag, True, # Can be toggled by user - 0, 16, grid_width, 1, + 0, 21, grid_width, 1, ) checkbutton7.set_hexpand(False) checkbutton7.connect('toggled', self.on_auto_clone_button_toggled) @@ -6095,52 +6289,6 @@ class SystemPrefWin(GenericPrefWin): ) checkbutton2.connect('toggled', self.on_json_button_toggled) - # Message filter preferences - self.add_label(grid, - 'Message filter preferences', - 0, 10, grid_width, 1, - ) - - checkbutton3 = self.add_checkbutton(grid, - 'Ignore \'Requested formats are incompatible for merge\' warnings', - self.app_obj.ignore_merge_warning_flag, - True, # Can be toggled by user - 0, 11, grid_width, 1, - ) - checkbutton3.connect('toggled', self.on_merge_button_toggled) - - checkbutton4 = self.add_checkbutton(grid, - 'Ignore YouTube copyright errors', - self.app_obj.ignore_yt_copyright_flag, - True, # Can be toggled by user - 0, 12, grid_width, 1, - ) - checkbutton4.connect('toggled', self.on_copyright_button_toggled) - - checkbutton5 = self.add_checkbutton(grid, - 'Ignore \'Child process exited with non-zero code\' errors', - self.app_obj.ignore_child_process_exit_flag, - True, # Can be toggled by user - 0, 13, grid_width, 1, - ) - checkbutton5.connect('toggled', self.on_child_process_button_toggled) - - checkbutton6 = self.add_checkbutton(grid, - 'Ignore \'There are no annotations to write\' warnings', - self.app_obj.ignore_no_annotations_flag, - True, # Can be toggled by user - 0, 14, grid_width, 1, - ) - checkbutton6.connect('toggled', self.on_no_annotations_button_toggled) - - checkbutton7 = self.add_checkbutton(grid, - 'Ignore \'Video doesn\'t have subtitles\' warnings', - self.app_obj.ignore_no_subtitles_flag, - True, # Can be toggled by user - 0, 15, grid_width, 1, - ) - checkbutton7.connect('toggled', self.on_no_subtitles_button_toggled) - def setup_output_tab(self): @@ -6157,171 +6305,203 @@ class SystemPrefWin(GenericPrefWin): 0, 0, 1, 1, ) + checkbutton = self.add_checkbutton(grid, - 'Display output from youtube-dl\'s STDOUT in the Output Tab', - self.app_obj.ytdl_output_stdout_flag, + 'Display youtube-dl system commands in the Output Tab', + self.app_obj.ytdl_output_system_cmd_flag, True, # Can be toggled by user 0, 1, 1, 1, ) checkbutton.set_hexpand(False) - # Signal connect appears below + checkbutton.connect('toggled', self.on_output_system_button_toggled) checkbutton2 = self.add_checkbutton(grid, - '...but don\'t write each video\'s JSON data', - self.app_obj.ytdl_output_ignore_json_flag, + 'Display output from youtube-dl\'s STDOUT in the Output Tab', + self.app_obj.ytdl_output_stdout_flag, True, # Can be toggled by user 0, 2, 1, 1, ) checkbutton2.set_hexpand(False) - checkbutton2.connect('toggled', self.on_output_json_button_toggled) - if not self.app_obj.ytdl_output_stdout_flag: - checkbutton2.set_sensitive(False) + # Signal connect appears below checkbutton3 = self.add_checkbutton(grid, - '...but don\'t write each video\'s download progress', - self.app_obj.ytdl_output_ignore_progress_flag, + '...but don\'t write each video\'s JSON data', + self.app_obj.ytdl_output_ignore_json_flag, True, # Can be toggled by user 0, 3, 1, 1, ) checkbutton3.set_hexpand(False) - checkbutton3.connect('toggled', self.on_output_progress_button_toggled) + checkbutton3.connect('toggled', self.on_output_json_button_toggled) if not self.app_obj.ytdl_output_stdout_flag: checkbutton3.set_sensitive(False) - # Signal connect from above - checkbutton.connect( - 'toggled', - self.on_output_stdout_button_toggled, - checkbutton2, - checkbutton3, - ) - checkbutton4 = self.add_checkbutton(grid, - 'Display output from youtube-dl\'s STDERR in the Output Tab', - self.app_obj.ytdl_output_stderr_flag, + '...but don\'t write each video\'s download progress', + self.app_obj.ytdl_output_ignore_progress_flag, True, # Can be toggled by user 0, 4, 1, 1, ) checkbutton4.set_hexpand(False) - checkbutton4.connect('toggled', self.on_output_stderr_button_toggled) + checkbutton4.connect('toggled', self.on_output_progress_button_toggled) + if not self.app_obj.ytdl_output_stdout_flag: + checkbutton4.set_sensitive(False) + + # Signal connect from above + checkbutton2.connect( + 'toggled', + self.on_output_stdout_button_toggled, + checkbutton3, + checkbutton4, + ) checkbutton5 = self.add_checkbutton(grid, - 'Empty pages in the Output Tab at the start of every operation', - self.app_obj.ytdl_output_start_empty_flag, + 'Display output from youtube-dl\'s STDERR in the Output Tab', + self.app_obj.ytdl_output_stderr_flag, True, # Can be toggled by user 0, 5, 1, 1, ) checkbutton5.set_hexpand(False) - checkbutton5.connect('toggled', self.on_output_empty_button_toggled) + checkbutton5.connect('toggled', self.on_output_stderr_button_toggled) + + checkbutton6 = self.add_checkbutton(grid, + 'Empty pages in the Output Tab at the start of every operation', + self.app_obj.ytdl_output_start_empty_flag, + True, # Can be toggled by user + 0, 6, 1, 1, + ) + checkbutton6.set_hexpand(False) + checkbutton6.connect('toggled', self.on_output_empty_button_toggled) + + checkbutton7 = self.add_checkbutton(grid, + 'Show a summary of active threads (changes are applied when ' \ + + utils.upper_case_first(__main__.__packagename__) + ' restarts', + self.app_obj.ytdl_output_show_summary_flag, + True, # Can be toggled by user + 0, 7, 1, 1, + ) + checkbutton7.set_hexpand(False) + checkbutton7.connect('toggled', self.on_output_summary_button_toggled) # Terminal window preferences self.add_label(grid, 'Terminal window preferences', - 0, 6, 1, 1, - ) - - checkbutton6 = self.add_checkbutton(grid, - 'Write output from youtube-dl\'s STDOUT to the terminal window', - self.app_obj.ytdl_write_stdout_flag, - True, # Can be toggled by user - 0, 7, 1, 1, - ) - checkbutton6.set_hexpand(False) - # Signal connect appears below - - checkbutton7 = self.add_checkbutton(grid, - '...but don\'t write each video\'s JSON data', - self.app_obj.ytdl_write_ignore_json_flag, - True, # Can be toggled by user 0, 8, 1, 1, ) - checkbutton7.set_hexpand(False) - checkbutton7.connect('toggled', self.on_terminal_json_button_toggled) - if not self.app_obj.ytdl_write_stdout_flag: - checkbutton7.set_sensitive(False) checkbutton8 = self.add_checkbutton(grid, - '...but don\'t write each video\'s download progress', - self.app_obj.ytdl_write_ignore_progress_flag, + 'Write youtube-dl system commands to the terminal window', + self.app_obj.ytdl_write_system_cmd_flag, True, # Can be toggled by user 0, 9, 1, 1, ) checkbutton8.set_hexpand(False) - checkbutton8.connect( - 'toggled', - self.on_terminal_progress_button_toggled, - ) - if not self.app_obj.ytdl_write_stdout_flag: - checkbutton8.set_sensitive(False) - - # Signal connect from above - checkbutton6.connect( - 'toggled', - self.on_terminal_stdout_button_toggled, - checkbutton7, - checkbutton8, - ) + checkbutton8.connect('toggled', self.on_terminal_system_button_toggled) checkbutton9 = self.add_checkbutton(grid, - 'Write output from youtube-dl\'s STDERR to the terminal window', - self.app_obj.ytdl_write_stderr_flag, + 'Write output from youtube-dl\'s STDOUT to the terminal window', + self.app_obj.ytdl_write_stdout_flag, True, # Can be toggled by user 0, 10, 1, 1, ) checkbutton9.set_hexpand(False) - checkbutton9.connect('toggled', self.on_terminal_stderr_button_toggled) + # Signal connect appears below + + checkbutton10 = self.add_checkbutton(grid, + '...but don\'t write each video\'s JSON data', + self.app_obj.ytdl_write_ignore_json_flag, + True, # Can be toggled by user + 0, 11, 1, 1, + ) + checkbutton10.set_hexpand(False) + checkbutton10.connect('toggled', self.on_terminal_json_button_toggled) + if not self.app_obj.ytdl_write_stdout_flag: + checkbutton10.set_sensitive(False) + + checkbutton11 = self.add_checkbutton(grid, + '...but don\'t write each video\'s download progress', + self.app_obj.ytdl_write_ignore_progress_flag, + True, # Can be toggled by user + 0, 12, 1, 1, + ) + checkbutton11.set_hexpand(False) + checkbutton11.connect( + 'toggled', + self.on_terminal_progress_button_toggled, + ) + if not self.app_obj.ytdl_write_stdout_flag: + checkbutton11.set_sensitive(False) + + # Signal connect from above + checkbutton9.connect( + 'toggled', + self.on_terminal_stdout_button_toggled, + checkbutton10, + checkbutton11, + ) + + checkbutton12 = self.add_checkbutton(grid, + 'Write output from youtube-dl\'s STDERR to the terminal window', + self.app_obj.ytdl_write_stderr_flag, + True, # Can be toggled by user + 0, 13, 1, 1, + ) + checkbutton12.set_hexpand(False) + checkbutton12.connect( + 'toggled', + self.on_terminal_stderr_button_toggled, + ) # Special preferences self.add_label(grid, 'Special preferences (applies to both the Output Tab and the' \ + ' terminal window)', - 0, 11, 1, 1, + 0, 14, 1, 1, ) - checkbutton10 = self.add_checkbutton(grid, + checkbutton13 = self.add_checkbutton(grid, 'Write verbose output (youtube-dl debugging mode)', self.app_obj.ytdl_write_verbose_flag, True, # Can be toggled by user - 0, 12, 1, 1, + 0, 15, 1, 1, ) - checkbutton10.set_hexpand(False) - checkbutton10.connect('toggled', self.on_verbose_button_toggled) + checkbutton13.set_hexpand(False) + checkbutton13.connect('toggled', self.on_verbose_button_toggled) # Refresh operation preferences self.add_label(grid, 'Refresh operation preferences', - 0, 13, 1, 1, + 0, 16, 1, 1, ) - checkbutton11 = self.add_checkbutton(grid, + checkbutton14 = self.add_checkbutton(grid, 'During a refresh operation, show all matching videos in the' \ + ' Output Tab', self.app_obj.refresh_output_videos_flag, True, # Can be toggled by user - 0, 14, 1, 1, + 0, 17, 1, 1, ) - checkbutton11.set_hexpand(False) + checkbutton14.set_hexpand(False) # Signal connect appears below - checkbutton12 = self.add_checkbutton(grid, + checkbutton15 = self.add_checkbutton(grid, '...also show all non-matching videos', self.app_obj.refresh_output_verbose_flag, True, # Can be toggled by user - 0, 15, 1, 1, + 0, 18, 1, 1, ) - checkbutton12.set_hexpand(False) - checkbutton12.connect( + checkbutton15.set_hexpand(False) + checkbutton15.connect( 'toggled', self.on_refresh_verbose_button_toggled, ) if not self.app_obj.refresh_output_videos_flag: - checkbutton10.set_sensitive(False) + checkbutton11.set_sensitive(False) # Signal connect from above - checkbutton11.connect( + checkbutton14.connect( 'toggled', self.on_refresh_videos_button_toggled, - checkbutton12, + checkbutton15, ) @@ -6644,6 +6824,26 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.set_close_to_tray_flag(False) + def on_convert_from_button_toggled(self, radiobutton, mode): + + """Called from callback in self.setup_operations_tab(). + + Set what happens when downloading a media.Video object whose URL + represents a channel/playlist. + + Args: + + radiobutton (Gtk.RadioButton): The widget clicked + + mode (str): The new value for the IV: 'disable', 'multi', + 'channel' or 'playlist' + + """ + + if radiobutton.get_active(): + self.app_obj.set_operation_convert_mode(mode) + + def on_copyright_button_toggled(self, checkbutton): """Called from callback in self.setup_ytdl_tab(). @@ -6978,6 +7178,29 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.set_operation_download_limit(int(text)) + def on_error_button_toggled(self, checkbutton): + + """Called from callback in self.setup_windows_tab(). + + Enables/disables system errors in the 'Errors/Warnings' tab. Toggling + the corresponding Gtk.CheckButton in the Errors/Warnings tab sets the + IV (and makes sure the two checkbuttons have the same status). + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + """ + + other_flag \ + = self.app_obj.main_win_obj.show_error_checkbutton.get_active() + + if (checkbutton.get_active() and not other_flag): + self.app_obj.main_win_obj.show_error_checkbutton.set_active(True) + elif (not checkbutton.get_active() and other_flag): + self.app_obj.main_win_obj.show_error_checkbutton.set_active(False) + + def on_expand_tree_toggled(self, checkbutton): """Called from callback in self.setup_general_tab(). @@ -7249,6 +7472,26 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.set_ytdl_output_start_empty_flag(False) + def on_output_summary_button_toggled(self, checkbutton): + + """Called from a callback in self.setup_output_tab(). + + Enables/disables displaying a summary page in the Output Tab. + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + """ + + if checkbutton.get_active() \ + and not self.app_obj.ytdl_output_show_summary_flag: + self.app_obj.set_ytdl_output_show_summary_flag(True) + elif not checkbutton.get_active() \ + and self.app_obj.ytdl_output_show_summary_flag: + self.app_obj.set_ytdl_output_show_summary_flag(False) + + def on_output_stderr_button_toggled(self, checkbutton): """Called from a callback in self.setup_ytdl_tab(). @@ -7343,6 +7586,26 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.set_ytdl_output_ignore_progress_flag(False) + def on_output_system_button_toggled(self, checkbutton): + + """Called from a callback in self.setup_ytdl_tab(). + + Enables/disables writing youtube-dl system commands to the Output Tab. + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + """ + + if checkbutton.get_active() \ + and not self.app_obj.ytdl_output_system_cmd_flag: + self.app_obj.set_ytdl_output_system_cmd_flag(True) + elif not checkbutton.get_active() \ + and self.app_obj.ytdl_output_system_cmd_flag: + self.app_obj.set_ytdl_output_system_cmd_flag(False) + + def on_refresh_videos_button_toggled(self, checkbutton, checkbutton2): """Called from a callback in self.setup_output_tab(). @@ -7708,6 +7971,26 @@ class SystemPrefWin(GenericPrefWin): self.app_obj.set_ytdl_write_ignore_progress_flag(False) + def on_terminal_system_button_toggled(self, checkbutton): + + """Called from a callback in self.setup_ytdl_tab(). + + Enables/disables writing youtube-dl system commands to the terminal. + + Args: + + checkbutton (Gtk.CheckButton): The widget clicked + + """ + + if checkbutton.get_active() \ + and not self.app_obj.ytdl_write_system_cmd_flag: + self.app_obj.set_ytdl_write_system_cmd_flag(True) + elif not checkbutton.get_active() \ + and self.app_obj.ytdl_write_system_cmd_flag: + self.app_obj.set_ytdl_write_system_cmd_flag(False) + + def on_update_combo_changed(self, combo): """Called from a callback in self.setup_ytdl_tab(). @@ -7750,7 +8033,9 @@ class SystemPrefWin(GenericPrefWin): """Called from callback in self.setup_general_tab(). - Enables/disables system warnings in the 'Errors/Warnings' tab. + Enables/disables system warnings in the 'Errors/Warnings' tab. Toggling + the corresponding Gtk.CheckButton in the Errors/Warnings tab sets the + IV (and makes sure the two checkbuttons have the same status). Args: @@ -7758,12 +8043,15 @@ class SystemPrefWin(GenericPrefWin): """ - if checkbutton.get_active() \ - and not self.app_obj.system_warning_show_flag: - self.app_obj.set_system_warning_show_flag(True) - elif not checkbutton.get_active() \ - and self.app_obj.system_warning_show_flag: - self.app_obj.set_system_warning_show_flag(False) + main_win_obj = self.app_obj.main_win_obj + + other_flag \ + = self.app_obj.main_win_obj.show_warning_checkbutton.get_active() + + if (checkbutton.get_active() and not other_flag): + main_win_obj.show_warning_checkbutton.set_active(True) + elif (not checkbutton.get_active() and other_flag): + main_win_obj.show_warning_checkbutton.set_active(False) def on_worker_button_toggled(self, checkbutton): diff --git a/tartube/downloads.py b/tartube/downloads.py index 9f77064..d881f63 100755 --- a/tartube/downloads.py +++ b/tartube/downloads.py @@ -152,6 +152,15 @@ class DownloadManager(threading.Thread): # objects which have been allocated to a worker) self.job_count = 0 + # If mainapp.TartubeApp.operation_convert_mode is set to any value + # other than 'disable', then a media.Video object whose URL + # represents a channel/playlist is converted into multiple new + # media.Video objects, one for each video actually downloaded + # The original media.Video object is added to this list, via a call to + # self.mark_video_as_doomed(). At the end of the whole download + # operation, any media.Video object in this list is destroyed + self.doomed_video_list = [] + # Code # ---- @@ -159,7 +168,7 @@ class DownloadManager(threading.Thread): # Create an object for converting download options stored in # downloads.DownloadWorker.options_list into a list of youtube-dl # command line options - self.options_parser_obj = options.OptionsParser(self) + self.options_parser_obj = options.OptionsParser(self.app_obj) # Create a list of downloads.DownloadWorker objects, each one handling # one of several simultaneous downloads @@ -334,6 +343,16 @@ class DownloadManager(threading.Thread): self.app_obj.main_win_obj.output_tab_update_pages, ) + # Any media.Video objects which have been marked as doomed, can now be + # destroyed + for video_obj in self.doomed_video_list: + self.app_obj.delete_video( + video_obj, + True, # Delete any files associated with the video + True, # Don't update the Video Index yet + True, # Don't update the Video Catalogue yet + ) + # When youtube-dl reports it is finished, there is a short delay before # the final downloaded video(s) actually exist in the filesystem # Therefore, mainwin.MainWin.progress_list_display_dl_stats() may not @@ -514,6 +533,32 @@ class DownloadManager(threading.Thread): return None + def mark_video_as_doomed(self, video_obj): + + """Called by VideoDownloader.check_dl_is_correct_type(). + + When youtube-dl reports the URL associated with a download item + object contains multiple videos (or potentially contains multiple + videos), then the URL represents a channel or playlist, not a video. + + If the channel/playlist was about to be downloaded into a media.Video + object, then the calling function takes action to prevent it. + + It then calls this function to mark the old media.Video object to be + destroyed, once the download operation is complete. + + Args: + + video_obj (media.Video): The video object whose URL is not a video, + and which must be destroyed + + """ + + if isinstance(video_obj, media.Video) \ + and not video_obj in self.doomed_video_list: + self.doomed_video_list.append(video_obj) + + def remove_worker(self, worker_obj): """Called by self.run(). @@ -779,8 +824,8 @@ class DownloadWorker(threading.Thread): self.download_item_obj = download_item_obj self.options_manager_obj = download_item_obj.options_manager_obj self.options_list = self.download_manager_obj.options_parser_obj.parse( - download_item_obj, - self.options_manager_obj.options_dict, + download_item_obj.media_data_obj, + self.options_manager_obj, ) self.available_flag = False @@ -1006,7 +1051,10 @@ class DownloadList(object): # (The manager might be specified by obj itself, or it might be # specified by obj's parent, or we might use the default # options.OptionsManager) - options_manager_obj = self.get_options_manager(media_data_obj) + options_manager_obj = utils.get_options_manager( + self.app_obj, + media_data_obj, + ) # Ignore private folders, and don't download any of their children # (because they are all children of some other non-private folder) @@ -1099,40 +1147,6 @@ class DownloadList(object): return None - def get_options_manager(self, media_data_obj): - - """Called by self.create_item() or by this function recursively. - - Fetches the options.OptionsManager which applies to the specified media - data object. - - The media data object might specify its own options.OptionsManager, or - we might have to use the parent's, or the parent's parent's (and so - on). As a last resort, use General Options Manager. - - Args: - - obj(media.Video, media.Channel, media.Playlist, media.Folder): - A media data object - - Returns: - - The options.OptionsManager object that applies to the specified - media data object - - """ - - if DEBUG_FUNC_FLAG: - utils.debug_time('dld 1026 get_options_manager') - - if media_data_obj.options_obj: - return media_data_obj.options_obj - elif media_data_obj.parent_obj: - return self.get_options_manager(media_data_obj.parent_obj) - else: - return self.app_obj.general_options_obj - - @synchronise(_SYNC_LOCK) def move_item_to_bottom(self, download_item_obj): @@ -1441,6 +1455,16 @@ class VideoDownloader(object): # The time to wait, in seconds self.last_sim_video_wait_time = 60 + # If mainapp.TartubeApp.operation_convert_mode is set to any value + # other than 'disable', then a media.Video object whose URL + # represents a channel/playlist is converted into multiple new + # media.Video objects, one for each video actually downloaded + # Flag set to True when self.download_item_obj.media_data_obj is a + # media.Video object, but a channel/playlist is detected (regardless + # of the value of mainapp.TartubeApp.operation_convert_mode) + self.url_is_not_video_flag = False + + # Code # ---- # Initialise IVs depending on whether this is a real or simulated @@ -1515,7 +1539,26 @@ class VideoDownloader(object): time.sleep(self.long_sleep_time) # Prepare a system command... - cmd_list = self.get_system_cmd() + cmd_list = utils.generate_system_cmd( + app_obj, + self.download_item_obj.media_data_obj, + self.download_worker_obj.options_list, + self.dl_sim_flag, + ) + + # ...display it in the Output Tab (if required)... + if app_obj.ytdl_output_system_cmd_flag: + space = ' ' + app_obj.main_win_obj.output_tab_write_system_cmd( + self.download_worker_obj.worker_id, + space.join(cmd_list), + ) + + # ...and the terminal (if required)... + if app_obj.ytdl_write_system_cmd_flag: + space = ' ' + print(space.join(cmd_list)) + # ...and create a new child process using that command self.create_child_process(cmd_list) @@ -1690,23 +1733,79 @@ class VideoDownloader(object): When youtube-dl reports the URL associated with the download item object contains multiple videos (or potentially contains multiple - videos), then the URL is a channel or playlist, not a video. + videos), then the URL represents a channel or playlist, not a video. + + This function checks whether a channel/playlist is about to be + downloaded into a media.Video object. If so, it takes action to prevent + that from happening. + + The action taken depends on the value of + mainapp.TartubeApp.operation_convert_mode. + + Return values: + False if a channel/playlist was about to be downloaded into a + media.Video object, which has since been replaced by a new + media.Channel/media.Playlist object + + True in all other situations (including when a channel/playlist was + about to be downloaded into a media.Video object, which was + not replaced by a new media.Channel/media.Playlist object) - Cannot store data for a channel or playlist in a media.Video object, - so stop the child process immediately and display a system error. """ if DEBUG_FUNC_FLAG: utils.debug_time('dld 1600 check_dl_is_correct_type') + app_obj = self.download_manager_obj.app_obj + media_data_obj = self.download_item_obj.media_data_obj + if isinstance(self.download_item_obj.media_data_obj, media.Video): - self.stop() - self.download_item_obj.media_data_obj.set_error( - 'The video \'' + self.download_item_obj.media_data_obj.name \ - + '\' has a source URL that points to a channel or a' \ - + ' playlist, not a video', - ) + # If the mode is 'disable', or if it the original media.Video + # object is contained in a channel or a playlist, then we must + # stop downloading this URL immediately + if app_obj.operation_convert_mode == 'disable' \ + or not isinstance( + self.download_item_obj.media_data_obj.parent_obj, + media.Folder, + ): + self.url_is_not_video_flag = True + + # Stop downloading this URL + self.stop() + media_data_obj.set_error( + 'The video \'' + media_data_obj.name \ + + '\' has a source URL that points to a channel or a' \ + + ' playlist, not a video', + ) + + # Don't allow self.confirm_sim_video() to be called + return False + + # Otherwise, we can create new media.Video objects for each + # video downloaded/checked. The new objects may be placd into a + # new media.Channel or media.Playlist object + elif not self.url_is_not_video_flag: + + self.url_is_not_video_flag = True + + # Mark the original media.Video object to be destroyed at the + # end of the download operation + self.download_manager_obj.mark_video_as_doomed(media_data_obj) + + if app_obj.operation_convert_mode != 'multi': + + # Create a new media.Channel or media.Playlist object and + # add it to the download manager + # Then halt this job, so the new channel/playlist object + # can be downloaded + self.convert_video_to_container() + + # Don't allow self.confirm_sim_video() to be called + return False + + # Do allow self.confirm_sim_video() to be called + return True def close(self): @@ -1749,21 +1848,37 @@ class VideoDownloader(object): utils.debug_time('dld 1649 confirm_new_video') if not self.video_num in self.video_check_dict: + + app_obj = self.download_manager_obj.app_obj self.video_check_dict[self.video_num] = filename # Create a new media.Video object for the video - app_obj = self.download_manager_obj.app_obj - video_obj = app_obj.create_video_from_download( - self.download_item_obj, - dir_path, - filename, - extension, - True, # Don't sort parent containers yet - ) + if self.url_is_not_video_flag: - # If downloading from a playlist, remember the video's index in - # that playlist - if isinstance(video_obj.parent_obj, media.Playlist): + video_obj = app_obj.convert_video_from_download( + self.download_item_obj.media_data_obj.parent_obj, + self.download_item_obj.options_manager_obj, + dir_path, + filename, + extension, + True, # Don't sort parent containers yet + ) + + else: + + video_obj = app_obj.create_video_from_download( + self.download_item_obj, + dir_path, + filename, + extension, + True, # Don't sort parent containers yet + ) + + # If downloading from a channel/playlist, remember the video's + # index. (The server supplies an index even for a channel, and + # the user might want to convert a channel to a playlist) + if isinstance(video_obj.parent_obj, media.Channel) \ + or isinstance(video_obj.parent_obj, media.Playlist): video_obj.set_index(self.video_num) # Fetch the options.OptionsManager object used for this download @@ -1995,9 +2110,32 @@ class VideoDownloader(object): # Does an existing media.Video object match this video? media_data_obj = self.download_item_obj.media_data_obj video_obj = None - if isinstance(media_data_obj, media.Video): + + if self.url_is_not_video_flag: + + # media_data_obj has a URL which represents a channel or playlist, + # but media_data_obj itself is a media.Video object + # media_data_obj's parent is a media.Folder object. Check its + # child objects, looking for a matching video + # (video_obj is set to None, if no match is found) + video_obj = media_data_obj.parent_obj.find_matching_video( + app_obj, + filename, + ) + + if not video_obj: + video_obj = media_data_obj.parent_obj.find_matching_video( + app_obj, + name, + ) + + elif isinstance(media_data_obj, media.Video): + + # media_data_obj is a media.Video object video_obj = media_data_obj + else: + # media_data_obj is a media.Channel or media.Playlist object. Check # its child objects, looking for a matching video # (video_obj is set to None, if no match is found) @@ -2011,15 +2149,28 @@ class VideoDownloader(object): # No matching media.Video object found, so create a new one new_flag = True - video_obj = app_obj.create_video_from_download( - self.download_item_obj, - path, - filename, - extension, - # Don't sort parent container objects yet; wait for - # mainwin.MainWin.results_list_update_row() to do it - True, - ) + if self.url_is_not_video_flag: + + video_obj = app_obj.convert_video_from_download( + self.download_item_obj.media_data_obj.parent_obj, + self.download_item_obj.options_manager_obj, + path, + filename, + extension, + # Don't sort parent container objects yet; wait for + # mainwin.MainWin.results_list_update_row() to do it + True, + ) + + else: + + video_obj = app_obj.create_video_from_download( + self.download_item_obj, + path, + filename, + extension, + True, + ) # Update its IVs with the JSON information we extracted if filename is not None: @@ -2045,10 +2196,11 @@ class VideoDownloader(object): app_obj.main_win_obj.descrip_line_max_len, ) - # Only save the playlist index when this video is actually stored - # inside a media.Playlist object - if isinstance(video_obj.parent_obj, media.Playlist) \ - and playlist_index is not None: + # If downloading from a channel/playlist, remember the video's + # index. (The server supplies an index even for a channel, and + # the user might want to convert a channel to a playlist) + if isinstance(video_obj.parent_obj, media.Channel) \ + or isinstance(video_obj.parent_obj, media.Playlist): video_obj.set_index(playlist_index) # Now we can sort the parent containers @@ -2113,11 +2265,11 @@ class VideoDownloader(object): app_obj.main_win_obj.descrip_line_max_len, ) - # Only save the playlist index when this video is actually stored - # inside a media.Playlist object - if not video_obj.index \ - and isinstance(video_obj.parent_obj, media.Playlist) \ - and playlist_index is not None: + # If downloading from a channel/playlist, remember the video's + # index. (The server supplies an index even for a channel, and + # the user might want to convert a channel to a playlist) + if isinstance(video_obj.parent_obj, media.Channel) \ + or isinstance(video_obj.parent_obj, media.Playlist): video_obj.set_index(playlist_index) # Deal with the video description, JSON data and thumbnail, according @@ -2248,6 +2400,104 @@ class VideoDownloader(object): self.stop_now_flag = True + def convert_video_to_container (self): + + """Called by self.check_dl_is_correct_type(). + + Creates a new media.Channel or media.Playlist object to replace an + existing media.Video object. The new object is given some of the + properties of the old one. + + This function doesn't destroy the old object; DownloadManager.run() + handles that. + """ + + app_obj = self.download_manager_obj.app_obj + old_video_obj = self.download_item_obj.media_data_obj + container_obj = old_video_obj.parent_obj + + # Some media.Folder objects cannot contain channels or playlists (for + # example, the 'Unsorted Videos' folder) + # If that is the case, the new channel/playlist is created without a + # parent. Otherwise, it is created at the same location as the + # original media.Video object + if container_obj.restrict_flag: + container_obj = None + + # Decide on a name for the new channel/playlist, e.g. 'channel_1' or + # 'playlist_4'. The name must not already be in use. The user can + # customise the name when they're ready + # (Prevent any possibility of an infinite loop by giving up after + # thousands of attempts) + name = None + new_container_obj = None + + for n in range (1, 9999): + test_name = app_obj.operation_convert_mode + '_' + str(n) + if not test_name in app_obj.media_name_dict: + name = test_name + break + + if name is not None: + + # Create the new channel/playlist. Very unlikely that the old + # media.Video object has its .dl_sim_flag set, but we'll use it + # nonetheless + if app_obj.operation_convert_mode == 'channel': + + new_container_obj = app_obj.add_channel( + name, + container_obj, # May be None + source = old_video_obj.source, + dl_sim_flag = old_video_obj.dl_sim_flag, + ) + + else: + + new_container_obj = app_obj.add_playlist( + name, + container_obj, # May be None + source = old_video_obj.source, + dl_sim_flag = old_video_obj.dl_sim_flag, + ) + + if new_container_obj is None: + + # New channel/playlist could not be created (for some reason), so + # stop downloading from this URL + self.stop() + media_data_obj.set_error( + 'The video \'' + media_data_obj.name \ + + '\' has a source URL that points to a channel or a' \ + + ' playlist, not a video', + ) + + else: + + # Update IVs for the new channel/playlist object + new_container_obj.set_options_obj(old_video_obj.options_obj) + new_container_obj.set_source(old_video_obj.source) + + # Add the new channel/playlist to the Video Index (but don't + # select it) + app_obj.main_win_obj.video_index_add_row(new_container_obj, True) + + # Add the new channel/playlist to the download manager's list of + # things to download... + new_download_item_obj \ + = self.download_manager_obj.download_list_obj.create_item( + new_container_obj, + ) + # ...and add a row the Progress List + app_obj.main_win_obj.progress_list_add_row( + new_download_item_obj.item_id, + new_download_item_obj.media_data_obj, + ) + + # Stop this download job, allowing the replacement one to start + self.stop() + + def create_child_process(self, cmd_list): """Called by self.do_download() immediately after the call to @@ -2453,8 +2703,8 @@ class VideoDownloader(object): dl_stat_dict['playlist_size'] = stdout_list[5] self.video_total = stdout_list[5] - # If downloading an individual video, rather than a channel or - # a playlist, stop the download immediately + # If youtube-dl is about to download a channel or playlist into + # a media.Video object, decide what to do to prevent it self.check_dl_is_correct_type() # Remove the 'and merged' part of the STDOUT message when using @@ -2565,15 +2815,26 @@ class VideoDownloader(object): 'Invalid JSON data received from server', ) - # (JSON is valid) - self.confirm_sim_video(json_dict) + if json_dict: - self.video_num += 1 - dl_stat_dict['playlist_index'] = self.video_num - self.video_total += 1 - dl_stat_dict['playlist_size'] = self.video_total + # If youtube-dl is about to download a channel or playlist + # into a media.Video object, decide what to do to prevent + # The called function returns a True/False value, + # specifically to allow this code block to call + # self.confirm_sim_video when required + # v1.3.063 At this poitn, self.video_num can be None or 0 + # for a URL that's an individual video, but > 0 for a URL + # that's actually a channel/playlist + if not self.video_num \ + or self.check_dl_is_correct_type(): + self.confirm_sim_video(json_dict) - dl_stat_dict['status'] = formats.ACTIVE_STAGE_CHECKING + self.video_num += 1 + dl_stat_dict['playlist_index'] = self.video_num + self.video_total += 1 + dl_stat_dict['playlist_size'] = self.video_total + + dl_stat_dict['status'] = formats.ACTIVE_STAGE_CHECKING elif stdout_list[0][0] != '[' or stdout_list[0] == '[debug]': @@ -2621,68 +2882,6 @@ class VideoDownloader(object): dl_stat_dict['status'] = None - def get_system_cmd(self): - - """Called by self.do_download(). - - Based on YoutubeDLDownloader._get_cmd(). - - Prepare the system command that creates the child process, executing - youtube-dl. - - Returns: - - Python list that contains the system command to execute. - - """ - - if DEBUG_FUNC_FLAG: - utils.debug_time('dld 2540 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 - # required - 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 - elif app_obj.allow_ytdl_archive_flag: - - # (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 app_obj.ytdl_write_verbose_flag: - options_list.append('--verbose') - - # Supply youtube-dl with the path to the ffmpeg/avconv binary, if the - # user has provided one - if app_obj.ffmpeg_path is not None: - options_list.append('--ffmpeg-location') - options_list.append('"' + app_obj.ffmpeg_path + '"') - - # Set the list - cmd_list = [app_obj.ytdl_path] + options_list + [media_data_obj.source] - - return cmd_list - - def is_child_process_alive(self): """Called by self.do_download() and self.stop(). diff --git a/tartube/mainapp.py b/tartube/mainapp.py index 6ef7538..de8316f 100755 --- a/tartube/mainapp.py +++ b/tartube/mainapp.py @@ -28,7 +28,6 @@ from gi.repository import Gtk, GObject, GdkPixbuf # Import Python standard modules from gi.repository import Gio -import cgi import datetime import json import math @@ -255,8 +254,14 @@ class TartubeApp(Gtk.Application): # the most recently checked or downloaded video appears at the top # of the list) self.results_list_reverse_flag = False - # Flag set to True if system warning messages should be shown (system - # error messages are always shown) + # Flag set to True if system error messages should be shown in the + # Errors/Warnings tab + # NB The check is applied by self.system_error(); any part of the + # code could call mainwin.MainWin.errors_list_add_system_warning() + # directly, which would bypass this flag + self.system_error_show_flag = True + # Flag set to True if system warning messages should be shown in the + # Errors/Warnings tab # NB The check is applied by self.system_warning(); any part of the # code could call mainwin.MainWin.errors_list_add_system_warning() # directly, which would bypass this flag @@ -388,8 +393,11 @@ class TartubeApp(Gtk.Application): # self.ytdl_update_dict, set by self.start() self.ytdl_update_current = None - # Flag set to True if output youtube-dl's STDOUT should be displayed in + # Flag set to True if youtube-dl system commands should be displayed in # the Output Tab + self.ytdl_output_system_cmd_flag = True + # Flag set to True if youtube-dl's STDOUT should be displayed in the + # Output Tab self.ytdl_output_stdout_flag = True # Flag set to True if we should ignore JSON output when displaying text # in the Output Tab (ignored if self.ytdl_output_stdout_flag is @@ -405,9 +413,15 @@ class TartubeApp(Gtk.Application): # Flag set to True if pages in the Output Tab should be emptied at the # start of each operation self.ytdl_output_start_empty_flag = True + # Flag set to True if a summary page should be visible in the Output + # Tab. Changes to this flag are applied when Tartube restarts + self.ytdl_output_show_summary_flag = False - # Flag set to True if output youtube-dl's STDOUT should be written to + # Flag set to True if youtube-dl system commands should be written to # the terminal window + self.ytdl_write_system_cmd_flag = False + # Flag set to True if youtube-dl's STDOUT should be written to the + # terminal window self.ytdl_write_stdout_flag = False # Flag set to True if we should ignore JSON output when writing to the # terminal window (ignored if self.ytdl_write_stdout_flag is False) @@ -704,6 +718,25 @@ class TartubeApp(Gtk.Application): # desktop notification, or 'default' to do neither # NB Desktop notifications don't work on MS Windows self.operation_dialogue_mode = 'dialogue' + # What to do when the user creates a media.Video object whose URL + # represents a channel or playlist + # 'channel' to create a new media.Channel object, and place all the + # downloaded videos inside it (the original media.Video object is + # destroyed) + # 'playlist' to create a new media.Playlist object, and place all the + # downloaded videos inside it (the original media.Video object is + # destroyed) + # 'multi' to create a new media.Video object for each downloaded video, + # placed in the same folder as the original media.Video object (the + # original is destroyed) + # 'disable' to download nothing from the URL + # There are some restrictions. If the original media.Video object is + # contained in a folder whose .restrict_flag is False, and if the + # mode is 'channel' or 'playlist', then the new channel/playlist is + # not created in that folder. If the original media.Video object is + # contained in a channel or playlist, all modes to default to + # 'disable' + self.operation_convert_mode = 'channel' # Flag set to True if self.update_video_from_filesystem() should get # the video duration, if not already known, using the moviepy.editor # module (an optional dependency) @@ -1687,8 +1720,8 @@ class TartubeApp(Gtk.Application): Error codes for this function and for self.system_warning are currently assigned thus: - 100-199: mainapp.py (in use: 101-134) - 200-299: mainwin.py (in use: 201-239) + 100-199: mainapp.py (in use: 101-135) + 200-299: mainwin.py (in use: 201-240) 300-399: downloads.py (in use: 301-304) 400-499: config.py (in use: 401-404) @@ -1697,7 +1730,7 @@ class TartubeApp(Gtk.Application): if DEBUG_FUNC_FLAG: utils.debug_time('app 1696 system_error') - if self.main_win_obj: + if self.main_win_obj and self.system_error_show_flag: self.main_win_obj.errors_list_add_system_error(error_code, msg) else: # Emergency fallback: display in the terminal window @@ -1834,6 +1867,9 @@ class TartubeApp(Gtk.Application): if version >= 1000029: # v1.0.029 self.results_list_reverse_flag \ = json_dict['results_list_reverse_flag'] + if version >= 1003069: # v1.3.069 + self.system_error_show_flag \ + = json_dict['system_error_show_flag'] if version >= 6006: # v0.6.006 self.system_warning_show_flag \ = json_dict['system_warning_show_flag'] @@ -1864,6 +1900,9 @@ class TartubeApp(Gtk.Application): self.ytdl_update_list = json_dict['ytdl_update_list'] self.ytdl_update_current = json_dict['ytdl_update_current'] + if version >= 1003074: # v1.3.074 + self.ytdl_output_system_cmd_flag \ + = json_dict['ytdl_output_system_cmd_flag'] if version >= 1002030: # v1.2.030 self.ytdl_output_stdout_flag = json_dict['ytdl_output_stdout_flag'] self.ytdl_output_ignore_json_flag \ @@ -1873,7 +1912,13 @@ class TartubeApp(Gtk.Application): self.ytdl_output_stderr_flag = json_dict['ytdl_output_stderr_flag'] self.ytdl_output_start_empty_flag \ = json_dict['ytdl_output_start_empty_flag'] + if version >= 1003064: # v1.3.064 + self.ytdl_output_show_summary_flag \ + = json_dict['ytdl_output_show_summary_flag'] + if version >= 1003074: # v1.3.074 + self.ytdl_write_system_cmd_flag \ + = json_dict['ytdl_write_system_cmd_flag'] self.ytdl_write_stdout_flag = json_dict['ytdl_write_stdout_flag'] if version >= 5004: # v0.5.004 self.ytdl_write_ignore_json_flag \ @@ -1932,6 +1977,8 @@ class TartubeApp(Gtk.Application): # self.operation_dialogue_flag = json_dict['operation_dialogue_flag'] if version >= 1003028: # v1.3.028 self.operation_dialogue_mode = json_dict['operation_dialogue_mode'] + if version >= 1003060: # v1.3.060 + self.operation_convert_mode = json_dict['operation_convert_mode'] self.use_module_moviepy_flag = json_dict['use_module_moviepy_flag'] # # Removed v0.5.003 @@ -2085,6 +2132,7 @@ class TartubeApp(Gtk.Application): 'close_to_tray_flag': self.close_to_tray_flag, 'results_list_reverse_flag': self.results_list_reverse_flag, + 'system_error_show_flag': self.system_error_show_flag, 'system_warning_show_flag': self.system_warning_show_flag, 'system_msg_keep_totals_flag': self.system_msg_keep_totals_flag, @@ -2099,13 +2147,17 @@ class TartubeApp(Gtk.Application): 'ytdl_update_list': self.ytdl_update_list, 'ytdl_update_current': self.ytdl_update_current, + 'ytdl_output_system_cmd_flag': self.ytdl_output_system_cmd_flag, 'ytdl_output_stdout_flag': self.ytdl_output_stdout_flag, 'ytdl_output_ignore_json_flag': self.ytdl_output_ignore_json_flag, 'ytdl_output_ignore_progress_flag': \ self.ytdl_output_ignore_progress_flag, 'ytdl_output_stderr_flag': self.ytdl_output_stderr_flag, 'ytdl_output_start_empty_flag': self.ytdl_output_start_empty_flag, + 'ytdl_output_show_summary_flag': \ + self.ytdl_output_show_summary_flag, + 'ytdl_write_system_cmd_flag': self.ytdl_write_system_cmd_flag, 'ytdl_write_stdout_flag': self.ytdl_write_stdout_flag, 'ytdl_write_ignore_json_flag': self.ytdl_write_ignore_json_flag, 'ytdl_write_ignore_progress_flag': \ @@ -2144,6 +2196,7 @@ class TartubeApp(Gtk.Application): 'operation_auto_update_flag': self.operation_auto_update_flag, 'operation_save_flag': self.operation_save_flag, 'operation_dialogue_mode': self.operation_dialogue_mode, + 'operation_convert_mode': self.operation_convert_mode, 'use_module_moviepy_flag': self.use_module_moviepy_flag, 'dialogue_copy_clipboard_flag': self.dialogue_copy_clipboard_flag, @@ -2814,7 +2867,7 @@ class TartubeApp(Gtk.Application): os.remove(daily_bu_path) shutil.move(temp_bu_path, daily_bu_path) - + else: os.remove(temp_bu_path) @@ -3126,6 +3179,7 @@ class TartubeApp(Gtk.Application): self.fixed_all_folder = self.add_folder( 'All Videos', None, # No parent folder + False, # Allow downloads True, # Fixed (folder cannot be removed) True, # Private True, # Can only contain videos @@ -3135,6 +3189,7 @@ class TartubeApp(Gtk.Application): self.fixed_fav_folder = self.add_folder( 'Favourite Videos', None, # No parent folder + False, # Allow downloads True, # Fixed (folder cannot be removed) True, # Private True, # Can only contain videos @@ -3145,6 +3200,7 @@ class TartubeApp(Gtk.Application): self.fixed_new_folder = self.add_folder( 'New Videos', None, # No parent folder + False, # Allow downloads True, # Fixed (folder cannot be removed) True, # Private True, # Can only contain videos @@ -3154,6 +3210,7 @@ class TartubeApp(Gtk.Application): self.fixed_temp_folder = self.add_folder( 'Temporary Videos', None, # No parent folder + False, # Allow downloads True, # Fixed (folder cannot be removed) False, # Public False, # Can contain any media data object @@ -3163,6 +3220,7 @@ class TartubeApp(Gtk.Application): self.fixed_misc_folder = self.add_folder( 'Unsorted Videos', None, # No parent folder + False, # Allow downloads True, # Fixed (folder cannot be removed) False, # Public True, # Can only contain videos @@ -4131,7 +4189,6 @@ class TartubeApp(Gtk.Application): # (Download operation support functions) - def create_video_from_download(self, download_item_obj, dir_path, \ filename, extension, no_sort_flag=False): @@ -4221,6 +4278,7 @@ class TartubeApp(Gtk.Application): video_obj = self.add_video( other_parent_obj, None, + False, no_sort_flag, ) @@ -4228,6 +4286,7 @@ class TartubeApp(Gtk.Application): video_obj = self.add_video( media_data_obj, None, + False, no_sort_flag, ) @@ -4248,6 +4307,99 @@ class TartubeApp(Gtk.Application): return video_obj + def convert_video_from_download(self, container_obj, options_manager_obj, + dir_path, filename, extension, no_sort_flag=False): + + """Called downloads.VideoDownloader.confirm_new_video() and + .confirm_sim_video(). + + A modified version of self.create_video_from_download, called when + youtube-dl is about to download a channel or playlist into a + media.Video object. + + Args: + + container_obj (media.Folder): The folder into which a replacement + media.Video object is to be created + + options_manager_obj (options.OptionsManager): The download options + for this media data object + + dir_path (string): The full path to the directory in which the + video is saved, e.g. '/home/yourname/tartube/downloads/Videos' + + filename (string): The video's filename, e.g. 'My Video' + + extension (string): The video's extension, e.g. '.mp4' + + no_sort_flag (True or False): True when called by + downloads.VideoDownloader.confirm_sim_video(), because the + video's parent containers (including the 'All Videos' folder) + should delay sorting their lists of child objects until that + calling function is ready. False when called by anything else + + Returns: + + video_obj (media.Video) - The video object created + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 4166 convert_video_from_download') + + # Does the container object already contain this video? + video_obj = None + for child_obj in container_obj.child_list: + + child_file_dir = None + if child_obj.file_dir is not None: + child_file_dir = os.path.abspath( + os.path.join( + self.downloads_dir, + child_obj.file_dir, + ), + ) + + if isinstance(child_obj, media.Video) \ + and child_file_dir \ + and child_file_dir == dir_path \ + and child_obj.file_name \ + and child_obj.file_name == filename: + video_obj = child_obj + + if video_obj is None: + + # Create a new media data object for the video + override_name \ + = 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_container_obj = self.media_reg_dict[other_dbid] + + video_obj = self.add_video( + other_container_obj, + None, + False, + no_sort_flag, + ) + + else: + video_obj = self.add_video( + container_obj, + None, + False, + no_sort_flag, + ) + + # Since we have them to hand, set the video's file path IVs + # immediately + video_obj.set_file(filename, extension) + + return video_obj + + def announce_video_download(self, download_item_obj, video_obj, \ keep_description=None, keep_info=None, keep_annotations=None, keep_thumbnail=None): @@ -4731,7 +4883,8 @@ class TartubeApp(Gtk.Application): # (Add media data objects) - def add_video(self, parent_obj, source=None, no_sort_flag=False): + def add_video(self, parent_obj, source=None, dl_sim_flag=False, + no_sort_flag=False): """Can be called by anything. Mostly called by self.create_video_from_download() and self.on_menu_add_video(). @@ -4746,7 +4899,10 @@ class TartubeApp(Gtk.Application): source (string): The video's source URL, if known - no_sort_flag (True or False): True when + dl_sim_flag (bool): If True, the video object's .dl_sim_flag IV is + set to True, which forces simulated downloads + + no_sort_flag (bool): True when self.create_video_from_download() is called by downloads.VideoDownloader.confirm_sim_video(), because the video's parent containers (including the 'All Videos' folder) @@ -4789,6 +4945,9 @@ class TartubeApp(Gtk.Application): if source is not None: video_obj.set_source(source) + if dl_sim_flag: + video_obj.set_dl_sim_flag(True) + # Update IVs self.media_reg_count += 1 self.media_reg_dict[video_obj.dbid] = video_obj @@ -4974,8 +5133,8 @@ class TartubeApp(Gtk.Application): return playlist_obj - def add_folder(self, name, parent_obj=None, fixed_flag=False, \ - priv_flag=False, restrict_flag=False, temp_flag=False): + def add_folder(self, name, parent_obj=None, dl_sim_flag=False, + fixed_flag=False, priv_flag=False, restrict_flag=False, temp_flag=False): """Can be called by anything. Mostly called by self.on_menu_add_folder(). @@ -4989,8 +5148,12 @@ class TartubeApp(Gtk.Application): parent_obj (media.Folder): The media data object for which the new media.Channel object is a child (if any) - fixed_flag, priv_flag, restrict_flag, temp_flag (True, False): - flags sent to the object's .__init__() function + dl_sim_flag (bool): If True, the folders .dl_sim_flag IV is set to + True, which forces simulated downloads for any videos, + channels or playlists contained in the folder + + fixed_flag, priv_flag, restrict_flag, temp_flag (bool): Flags sent + to the object's .__init__() function Returns: @@ -5033,6 +5196,9 @@ class TartubeApp(Gtk.Application): temp_flag, ) + if dl_sim_flag: + folder_obj.set_dl_sim_flag(True) + # Update IVs self.media_reg_count += 1 self.media_reg_dict[folder_obj.dbid] = folder_obj @@ -5369,6 +5535,96 @@ class TartubeApp(Gtk.Application): self.main_win_obj.video_index_select_row(source_obj) + # (Convert channels to playlists, and vice-versa) + + + def convert_remote_container(self, old_obj): + + """Called by mainwin.MainWin.on_video_index_convert_container(). + + Converts a media.Channel object into a media.Playlist object, or vice- + versa. + + Usually called after the user has copy-pasted a list of URLs into the + mainwin.AddVideoDialogue window, some of which actually represent + channels or playlists, not individual videos. During the next + download operation, new channels or playlists can be automatically + created (depending on the value of self.operation_convert_mode + + The user can then convert a channel to a playlist, and back again, as + required. + + Args: + + old_obj (media.Channel, media.Playlist): The media data object to + convert + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 5392 delete_video') + + if ( + not isinstance(old_obj, media.Channel) \ + and not isinstance(old_obj, media.Playlist) + ) or self.current_manager_obj: + return self.system_error( + 135, + 'Convert container request failed sanity check', + ) + + # If old_obj is a media.Channel, create a playlist. If old_obj is + # a media.Playlist, create a channel + if isinstance(old_obj, media.Channel): + + new_obj = self.add_playlist( + old_obj.name, + old_obj.parent_obj, + old_obj.source, + old_obj.dl_sim_flag, + ) + + elif isinstance(old_obj, media.Playlist): + + new_obj = self.add_channel( + old_obj.name, + old_obj.parent_obj, + old_obj.source, + old_obj.dl_sim_flag, + ) + + # Move any children from the old object to the new one + for child_obj in old_obj.child_list: + + # The True argument means to delay sorting the child list + new_obj.add_child(child_obj, True) + child_obj.set_parent_obj(new_obj) + + # Deal with alternative download destinations + if old_obj.master_dbid: + new_obj.set_master_dbid(self, old_obj.master_dbid) + master_obj = self.media_reg_dict[old_obj.master_dbid] + master_obj.del_slave_dbid(old_obj.dbid) + + for slave_dbid in old_obj.slave_dbid_list: + slave_obj = self.media_reg_dict[slave_dbid] + slave_obj.set_master_dbid(self, new_obj.dbid) + + # Copy remaining properties from the old object to the new one + new_obj.clone_properties(old_obj) + + # Remove the old object from the media data registry. + # self.media_name_dict should already be updated + del self.media_reg_dict[old_obj.dbid] + if old_obj.dbid in self.media_top_level_list: + self.media_top_level_list.remove(old_obj.dbid) + + # Remove the old object from the Video Index... + self.main_win_obj.video_index_delete_row(old_obj) + # ...and add the new one, selecting it at the same time + self.main_win_obj.video_index_add_row(new_obj) + + # (Delete media data objects) @@ -7282,7 +7538,7 @@ class TartubeApp(Gtk.Application): ) else: - utils.open_file(cgi.escape(path, quote=True)) + utils.open_file(path) def download_watch_videos(self, video_list, watch_flag=True): @@ -8155,6 +8411,7 @@ class TartubeApp(Gtk.Application): # Retrieve user choices from the dialogue window... name = dialogue_win.entry.get_text() + dl_sim_flag = dialogue_win.button2.get_active() # ...and find the name of the parent media data object (a # media.Folder), if one was specified... @@ -8197,7 +8454,7 @@ class TartubeApp(Gtk.Application): parent_obj = self.media_reg_dict[dbid] # Create the new folder - folder_obj = self.add_folder(name, parent_obj) + folder_obj = self.add_folder(name, parent_obj, dl_sim_flag) # Add the folder to the Video Index if folder_obj: @@ -8379,6 +8636,8 @@ class TartubeApp(Gtk.Application): False, ) + dl_sim_flag = dialogue_win.button2.get_active() + # ...and find the parent media data object (a media.Channel, # media.Playlist or media.Folder)... parent_name = self.fixed_misc_folder.name @@ -8412,7 +8671,7 @@ class TartubeApp(Gtk.Application): if parent_obj.check_duplicate_video(line): duplicate_list.append(line) else: - self.add_video(parent_obj, line) + self.add_video(parent_obj, line, dl_sim_flag) # In the Video Index, select the parent media data object, which # updates both the Video Index and the Video Catalogue @@ -9246,10 +9505,20 @@ class TartubeApp(Gtk.Application): self.operation_check_limit = value + def set_operation_convert_mode(self, mode): + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 9220 set_operation_convert_mode') + + if mode == 'disable' or mode == 'multi' or mode == 'channel' \ + or mode == 'playlist': + self.operation_convert_mode = mode + + def set_operation_dialogue_mode(self, mode): if DEBUG_FUNC_FLAG: - utils.debug_time('app 9220 set_operation_dialogue_mode') + utils.debug_time('app 9221 set_operation_dialogue_mode') if mode == 'default' or mode == 'desktop' or mode == 'dialogue': self.operation_dialogue_mode = mode @@ -9421,6 +9690,17 @@ class TartubeApp(Gtk.Application): self.main_win_obj.enable_tooltips(True) + def set_system_error_show_flag(self, flag): + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 9381 set_system_error_show_flag') + + if not flag: + self.system_error_show_flag = False + else: + self.system_error_show_flag = True + + def set_system_msg_keep_totals_flag(self, flag): if DEBUG_FUNC_FLAG: @@ -9435,7 +9715,7 @@ class TartubeApp(Gtk.Application): def set_system_warning_show_flag(self, flag): if DEBUG_FUNC_FLAG: - utils.debug_time('app 9406 xxset_system_warning_show_flagxxx') + utils.debug_time('app 9406 set_system_warning_show_flag') if not flag: self.system_warning_show_flag = False @@ -9535,10 +9815,21 @@ class TartubeApp(Gtk.Application): self.refresh_output_videos_flag = True + def set_ytdl_output_show_summary_flag(self, flag): + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 9509 set_ytdl_output_show_summary_flag') + + if not flag: + self.ytdl_output_show_summary_flag = False + else: + self.ytdl_output_show_summary_flag = True + + def set_ytdl_output_start_empty_flag(self, flag): if DEBUG_FUNC_FLAG: - utils.debug_time('app 9509 set_ytdl_output_start_empty_flag') + utils.debug_time('app 9510 set_ytdl_output_start_empty_flag') if not flag: self.ytdl_output_start_empty_flag = False @@ -9590,6 +9881,17 @@ class TartubeApp(Gtk.Application): self.ytdl_output_stdout_flag = True + def set_ytdl_output_system_cmd_flag(self, flag): + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 9554 set_ytdl_output_system_cmd_flag') + + if not flag: + self.ytdl_output_system_cmd_flag = False + else: + self.ytdl_output_system_cmd_flag = True + + def set_ytdl_path(self, path): if DEBUG_FUNC_FLAG: @@ -9650,6 +9952,17 @@ class TartubeApp(Gtk.Application): self.ytdl_write_stdout_flag = True + def set_ytdl_write_system_cmd_flag(self, flag): + + if DEBUG_FUNC_FLAG: + utils.debug_time('app 9614 set_ytdl_write_system_cmd_flag') + + if not flag: + self.ytdl_write_system_cmd_flag = False + else: + self.ytdl_write_system_cmd_flag = True + + def set_ytdl_write_verbose_flag(self, flag): if DEBUG_FUNC_FLAG: diff --git a/tartube/mainwin.py b/tartube/mainwin.py index 307a8a2..c4a4db4 100755 --- a/tartube/mainwin.py +++ b/tartube/mainwin.py @@ -27,7 +27,6 @@ from gi.repository import Gtk, GObject, Gdk, GdkPixbuf # Import other modules -import cgi import datetime from gi.repository import Gio import os @@ -45,6 +44,7 @@ if os.name != 'nt': # Import our modules import config import formats +import html import __main__ import mainapp import media @@ -166,6 +166,8 @@ class MainWin(Gtk.ApplicationWindow): self.errors_list_scrolled = None # Gtk.ScrolledWindow self.errors_list_treeview = None # Gtk.TreeView self.errors_list_liststore = None # Gtk.ListStore + self.show_error_checkbutton = None # Gtk.CheckButton + self.show_warning_checkbutton = None # Gtk.CheckButton self.error_list_button = None # Gtk.Button # (Widgets which must be (de)sensitised during download/update/refresh @@ -364,7 +366,8 @@ class MainWin(Gtk.ApplicationWindow): # Output Tab IVs # Flag set to True when the summary tab is added, during the first call - # to self.output_tab_setup_pages() + # to self.output_tab_setup_pages() (might not be added at all, if + # mainapp.TartubeApp.ytdl_output_show_summary_flag is False) self.output_tab_summary_flag = False # The number of pages in the Output Tab's notebook (not including the # summary tab). The number matches the highest value of @@ -376,7 +379,8 @@ class MainWin(Gtk.ApplicationWindow): # each page # Dictionary in the form # key = The page number (the summary page is #0, the first page for a - # thread is #1) + # thread is #1, regardless of whether the summary page is + # visible) # value = The corresponding Gtk.TextView object self.output_textview_dict = {} # When youtube-dl generates output, that text cannot be displayed in @@ -387,11 +391,18 @@ class MainWin(Gtk.ApplicationWindow): # calls self.output_tab_update() regularly to display the output in # the Output Tab (which empties the list) # List in groups of 3, in the form - # (page_number, mssage, error_flag...) + # (page_number, mssage, type...) # ...where 'page_number' matches a key in self.output_textview_dict, - # 'msg' is a string to display, and 'error_flag' is True for an - # error/warning message, False otherwise + # 'msg' is a string to display, and 'type' is 'system_cmd' for a + # system command (displayed in yellow, by default), 'error_warning' + # for an error/warning message (displayed in cyan, by default) and + # 'default' for everything else self.output_tab_insert_list = [] + # Colours used in the output tab + self.output_tab_bg_colour = '#000000' + self.output_tab_text_colour = '#FFFFFF' + self.output_tab_stderr_colour = 'cyan' + self.output_tab_system_cmd_colour = 'yellow' # Errors / Warnings Tab IVs # The number of errors added to the Error List, since this tab was the @@ -544,13 +555,12 @@ class MainWin(Gtk.ApplicationWindow): # Allow the user to drag-and-drop videos (for example, from the web # browser) into the main window, adding it the currently selected # folder (or to 'Unsorted Videos' if something else is selected) - # !!! v1.3.040 This code is very unreliable. I have asked for help and - # am waiting for a response - self.connect('drag_motion', self.on_window_drag_motion) - self.connect('drag_drop', self.on_window_drag_drop) self.connect('drag_data_received', self.on_window_drag_data_received) -# self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) - self.drag_dest_set(0, [], 0) + # (Without this line, we get Gtk warnings on some systems) + self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) + # (Continuing) + self.drag_dest_set_target_list(None) + self.drag_dest_add_text_targets() # Set up desktop notifications. Notifications can be sent by calling # self.notify_desktop() @@ -1327,8 +1337,8 @@ class MainWin(Gtk.ApplicationWindow): for i, column_title in enumerate( [ - 'hide', '', 'Source', 'Videos', 'Status', 'Incoming file', - 'Ext', 'Size', '%', 'ETA', 'Speed', + 'hide', 'hide', '', 'Source', '#', 'Status', 'Incoming file', + 'Ext', '%', 'Speed', 'ETA', 'Size', ] ): if not column_title: @@ -1355,7 +1365,7 @@ class MainWin(Gtk.ApplicationWindow): column_text.set_visible(False) self.progress_list_liststore = Gtk.ListStore( - int, + int, int, GdkPixbuf.Pixbuf, str, str, str, str, str, str, str, str, str, ) @@ -1596,11 +1606,32 @@ class MainWin(Gtk.ApplicationWindow): self.errors_list_treeview.set_model(self.errors_list_liststore) # Strip of widgets at the bottom - hbox = Gtk.HBox() vbox.pack_start(hbox, False, False, self.spacing_size) hbox.set_border_width(self.spacing_size) + self.show_error_checkbutton = Gtk.CheckButton() + hbox.pack_start(self.show_error_checkbutton, False, False, 0) + self.show_error_checkbutton.set_label('Show system errors') + self.show_error_checkbutton.set_active( + self.app_obj.system_error_show_flag, + ) + self.show_error_checkbutton.connect( + 'toggled', + self.on_show_error_checkbutton_changed, + ) + + self.show_warning_checkbutton = Gtk.CheckButton() + hbox.pack_start(self.show_warning_checkbutton, False, False, 0) + self.show_warning_checkbutton.set_label('Show system warnings') + self.show_warning_checkbutton.set_active( + self.app_obj.system_warning_show_flag, + ) + self.show_warning_checkbutton.connect( + 'toggled', + self.on_show_warning_checkbutton_changed, + ) + self.error_list_button = Gtk.Button() hbox.pack_end(self.error_list_button, False, False, 0) self.error_list_button.set_label('Clear the list') @@ -2562,6 +2593,29 @@ class MainWin(Gtk.ApplicationWindow): # Separator actions_submenu.append(Gtk.SeparatorMenuItem()) + convert_text = None + if isinstance(media_data_obj, media.Channel): + convert_text = 'Convert to playlist' + elif isinstance(media_data_obj, media.Playlist): + convert_text = 'Convert to channel' + else: + convert_text = None + + if convert_text: + + convert_menu_item = Gtk.MenuItem.new_with_mnemonic(convert_text) + convert_menu_item.connect( + 'activate', + self.on_video_index_convert_container, + media_data_obj, + ) + actions_submenu.append(convert_menu_item) + if self.app_obj.current_manager_obj: + convert_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( @@ -2684,6 +2738,19 @@ class MainWin(Gtk.ApplicationWindow): # Separator downloads_submenu.append(Gtk.SeparatorMenuItem()) + show_system_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Show _system command', + ) + show_system_menu_item.connect( + 'activate', + self.on_video_index_show_system_cmd, + media_data_obj, + ) + downloads_submenu.append(show_system_menu_item) + + # Separator + downloads_submenu.append(Gtk.SeparatorMenuItem()) + disable_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( 'D_isable checking/downloading', ) @@ -2947,7 +3014,8 @@ class MainWin(Gtk.ApplicationWindow): # Separator popup_menu.append(Gtk.SeparatorMenuItem()) - # Apply/remove/edit download options, disable downloads + # Apply/remove/edit download options, show system command, disable + # downloads downloads_submenu = Gtk.Menu() # (Desensitise these menu items, if an edit window is already open) @@ -3002,6 +3070,19 @@ class MainWin(Gtk.ApplicationWindow): # Separator downloads_submenu.append(Gtk.SeparatorMenuItem()) + show_system_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Show _system command', + ) + show_system_menu_item.connect( + 'activate', + self.on_video_catalogue_show_system_cmd, + video_obj, + ) + downloads_submenu.append(show_system_menu_item) + + # Separator + downloads_submenu.append(Gtk.SeparatorMenuItem()) + enforce_check_menu_item = Gtk.CheckMenuItem.new_with_mnemonic( 'D_isable downloads', ) @@ -3342,7 +3423,7 @@ class MainWin(Gtk.ApplicationWindow): popup_menu.popup(None, None, None, None, event.button, event.time) - def progress_list_popup_menu(self, event, item_id): + def progress_list_popup_menu(self, event, item_id, dbid): """Called by self.on_progress_list_right_click(). @@ -3356,6 +3437,8 @@ class MainWin(Gtk.ApplicationWindow): item_id (int): The .item_id of the clicked downloads.DownloadItem object + dbid (int): The .dbid of the corresponding media data object + """ if DEBUG_FUNC_FLAG: @@ -3382,6 +3465,11 @@ class MainWin(Gtk.ApplicationWindow): video_downloader_obj = this_worker_obj.video_downloader_obj break + # Find the media data object itself. If the download operation has + # finished, the variables just above will not be set + media_data_obj = None + if dbid in self.app_obj.media_reg_dict: + media_data_obj = self.app_obj.media_reg_dict[dbid] # Set up the popup menu popup_menu = Gtk.Menu() @@ -3445,6 +3533,54 @@ class MainWin(Gtk.ApplicationWindow): if not download_manager_obj or worker_obj: dl_last_menu_item.set_sensitive(False) + # Watch on website + if media_data_obj \ + and isinstance(media_data_obj, media.Video) \ + and media_data_obj.source: + + # Separator + popup_menu.append(Gtk.SeparatorMenuItem()) + + # For YouTube videos, offer two websites (as usual) + mod_source = utils.convert_youtube_to_hooktube( + media_data_obj.source, + ) + + if media_data_obj.source != mod_source: + + watch_youtube_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Watch on _YouTube', + ) + watch_youtube_menu_item.connect( + 'activate', + self.on_progress_list_watch_website, + media_data_obj, + ) + popup_menu.append(watch_youtube_menu_item) + + watch_hooktube_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Watch on _HookTube', + ) + watch_hooktube_menu_item.connect( + 'activate', + self.on_progress_list_watch_hooktube, + media_data_obj, + mod_source, + ) + popup_menu.append(watch_hooktube_menu_item) + + else: + + watch_website_menu_item = Gtk.MenuItem.new_with_mnemonic( + 'Watch on _Website', + ) + watch_website_menu_item.connect( + 'activate', + self.on_progress_list_watch_website, + media_data_obj, + ) + popup_menu.append(watch_website_menu_item) + # Create the popup menu popup_menu.show_all() popup_menu.popup(None, None, None, None, event.button, event.time) @@ -3623,7 +3759,7 @@ class MainWin(Gtk.ApplicationWindow): utils.debug_time('mwn 3595 add_watch_video_menu_items') # Watch video in player/download and watch - if not video_obj.dl_flag: + if not video_obj.dl_flag and not self.app_obj.current_manager_obj: dl_watch_menu_item = Gtk.MenuItem.new_with_mnemonic( 'D_ownload and watch', @@ -3691,14 +3827,6 @@ class MainWin(Gtk.ApplicationWindow): # Download to Temporary Videos temp_submenu = Gtk.Menu() - if not video_obj.source \ - or self.app_obj.update_manager_obj \ - or self.app_obj.refresh_manager_obj \ - or ( - isinstance(video_obj.parent_obj, media.Folder) - and video_obj.parent_obj.temp_flag - ): - temp_submenu.set_sensitive(False) temp_dl_menu_item = Gtk.MenuItem.new_with_mnemonic('_Download') temp_dl_menu_item.connect( @@ -3725,6 +3853,14 @@ class MainWin(Gtk.ApplicationWindow): ) temp_menu_item.set_submenu(temp_submenu) popup_menu.append(temp_menu_item) + if not video_obj.source \ + or self.app_obj.current_manager_obj \ + or ( + isinstance(video_obj.parent_obj, media.Folder) + and video_obj.parent_obj.temp_flag + ): + temp_menu_item.set_sensitive(False) + # (Video Index) @@ -5098,7 +5234,7 @@ class MainWin(Gtk.ApplicationWindow): # Reset widgets self.progress_list_liststore = Gtk.ListStore( - int, + int, int, GdkPixbuf.Pixbuf, str, str, str, str, str, str, str, str, str, ) @@ -5181,7 +5317,8 @@ class MainWin(Gtk.ApplicationWindow): # Prepare the new row in the treeview row_list = [] - row_list.append(item_id) + row_list.append(item_id) # Hidden + row_list.append(media_data_obj.dbid) # Hidden row_list.append(pixbuf) row_list.append( utils.shorten_string(media_data_obj.name, self.string_max_len), @@ -5292,19 +5429,19 @@ class MainWin(Gtk.ApplicationWindow): tree_path = Gtk.TreePath(self.progress_list_row_dict[item_id]) # Update statistics displayed in that row - # (Columns 0, 1 and 2 are not modified, once the row has been added - # to the treeview) - column = 2 + # (Columns 0, 1, 2 and 3 are not modified, once the row has been + # added to the treeview) + column = 3 for key in ( 'playlist_index', 'status', 'filename', 'extension', - 'filesize', 'percent', - 'eta', 'speed', + 'eta', + 'filesize', ): column += 1 @@ -5665,7 +5802,8 @@ class MainWin(Gtk.ApplicationWindow): # The first page in the Output Tab's notebook shows a summary of what # the threads created by downloads.py are doing - if not self.output_tab_summary_flag: + if not self.output_tab_summary_flag \ + and self.app_obj.ytdl_output_show_summary_flag: self.output_tab_add_page(True) self.output_tab_summary_flag = True @@ -5730,8 +5868,8 @@ class MainWin(Gtk.ApplicationWindow): style_provider = self.output_tab_set_textview_css( '#css_text_id_' + str(self.output_page_count) \ + ', textview text {\n' \ - + ' background-color: #000000;\n' \ - + ' color: #FFFFFF;\n' \ + + ' background-color: ' + self.output_tab_bg_colour + ';\n' \ + + ' color: ' + self.output_tab_text_colour + ';\n' \ + '}\n' \ + '#css_label_id_' + str(self.output_page_count) \ + ', textview {\n' \ @@ -5843,7 +5981,7 @@ class MainWin(Gtk.ApplicationWindow): if DEBUG_FUNC_FLAG: utils.debug_time('mwn 5794 output_tab_write_stdout') - self.output_tab_insert_list.extend( [page_num, msg, False] ) + self.output_tab_insert_list.extend( [page_num, msg, 'default'] ) def output_tab_write_stderr(self, page_num, msg): @@ -5871,7 +6009,34 @@ class MainWin(Gtk.ApplicationWindow): if DEBUG_FUNC_FLAG: utils.debug_time('mwn 5822 output_tab_write_stderr') - self.output_tab_insert_list.extend( [page_num, msg, True] ) + self.output_tab_insert_list.extend( [page_num, msg, 'error_warning'] ) + + + def output_tab_write_system_cmd(self, page_num, msg): + + """Called by downloads.VideoDownloader.do_download(). + + During a download operation, youtube-dl system commands are displayed + in the Output Tab (if permitted). However, they can't be displayed + immediately, because Gtk widgets can't be updated from within a thread. + + Instead, add the received values to a list, and wait for the GObject + timer mainapp.TartubeApp.dl_timer_id to call self.output_tab_update(). + + Args: + + page_num (int): The page number on which this message should be + displayed. Matches a key in self.output_textview_dict + + msg (str): The message to display. A newline character will be + added by self.output_tab_update_pages(). + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 5823 output_tab_write_system_cmd') + + self.output_tab_insert_list.extend( [page_num, msg, 'system_cmd'] ) def output_tab_update_pages(self): @@ -5880,51 +6045,77 @@ class MainWin(Gtk.ApplicationWindow): .refresh_timer_callback() and .refresh_manager_finished(). During a download operation, youtube-dl sends output to STDOUT/STDERR. - If permitted, this output is displayed in the Output Tab. However, it - can't be displayed immediately, because Gtk widgets can't be updated - from within a thread. + If permitted, this output is displayed in the Output Tab, along with + any system commands. - Instead, the output has been added to self.output_tab_insert_list. This - output can now be displayed (and the list can be emptied). + However, the text can't be displayed immediately, because Gtk widgets + can't be updated from within a thread. + + Instead, the text has been added to self.output_tab_insert_list, and + can now be displayed (and the list can be emptied). """ if DEBUG_FUNC_FLAG: utils.debug_time('mwn 5842 output_tab_update_pages') + update_dict = {} + if self.output_tab_insert_list: while self.output_tab_insert_list: page_num = self.output_tab_insert_list.pop(0) msg = self.output_tab_insert_list.pop(0) - error_flag = self.output_tab_insert_list.pop(0) + msg_type = self.output_tab_insert_list.pop(0) - # Add the output to the textview. STDERR messages are displayed - # in cyan text - textview = self.output_textview_dict[page_num] - textbuffer = textview.get_buffer() + # Add the output to the textview. STDERR messages and system + # commands are displayed in a different colour + # (The summary page is not necessarily visible) + if page_num in self.output_textview_dict: - if not error_flag: - textbuffer.insert(textbuffer.get_end_iter(), msg + '\n') - - else: - string = GObject.markup_escape_text( - '' + msg + '\n', - ) + textview = self.output_textview_dict[page_num] + textbuffer = textview.get_buffer() + update_dict[page_num] = textview - # The .markup_escape_text() call won't escape curly braces, - # so we need to replace those manually - string = re.sub('{', '(', string) - string = re.sub('}', ')', string) - - textbuffer.insert_markup( - textbuffer.get_end_iter(), - string.format('cyan'), - -1, - ) + if msg_type != 'default': + + # The .markup_escape_text() call won't escape curly + # braces, so we need to replace those manually + msg = re.sub('{', '(', msg) + msg = re.sub('}', ')', msg) + + string = '' \ + + GObject.markup_escape_text(msg) + '\n' + + if msg_type == 'system_cmd': + + textbuffer.insert_markup( + textbuffer.get_end_iter(), + string.format( + self.output_tab_system_cmd_colour, + ), + -1, + ) + + else: + + # STDERR + textbuffer.insert_markup( + textbuffer.get_end_iter(), + string.format(self.output_tab_stderr_colour), + -1, + ) + + else: + + # STDOUT + textbuffer.insert( + textbuffer.get_end_iter(), + msg + '\n', + ) # Make the new output visible - for textview in self.output_textview_dict.values(): + for textview in update_dict.values(): textview.show_all() @@ -6217,14 +6408,17 @@ class MainWin(Gtk.ApplicationWindow): row_list.append( utils.upper_case_first(__main__.__packagename__) + ' warning', ) +# row_list.append( +# utils.tidy_up_long_string('#' + str(error_code) + ': ' + msg) \ +# + '\n\n' + utils.tidy_up_long_string( +# 'To disable system warning messages, click Edit >' \ +# + ' System preferences... > Windows, and then deselect \'' \ +# + 'Show system warning messages in the \'Errors/Warnings\'' \ +# + ' tab\'', +# ), +# ) row_list.append( - utils.tidy_up_long_string('#' + str(error_code) + ': ' + msg) \ - + '\n\n' + utils.tidy_up_long_string( - 'To disable system warning messages, click Edit >' \ - + ' System preferences... > Windows, and then deselect \'' \ - + 'Show system warning messages in the \'Errors/Warnings\'' \ - + ' tab\'', - ), + utils.tidy_up_long_string('#' + str(error_code) + ': ' + msg), ) # Create a new row in the treeview. Doing the .show_all() first @@ -6294,36 +6488,6 @@ class MainWin(Gtk.ApplicationWindow): return False - def on_window_drag_motion(self, widget, context, x, y, time): - - """Called from callback in self.setup_win(). - - This function is required for detecting when the user drags and drops - videos (for example, from a web browser) into the main window. - """ - - if DEBUG_FUNC_FLAG: - utils.debug_time('mwn 6222 on_window_drag_motion') - - Gdk.drag_status(context,Gdk.DragAction.COPY, time) - # Returning True which means 'I accept this data' - return True - - - def on_window_drag_drop(self, widget, context, x, y, time): - - """Called from callback in self.setup_win(). - - This function is required for detecting when the user drags and drops - videos (for example, from a web browser) into the main window. - """ - - if DEBUG_FUNC_FLAG: - utils.debug_time('mwn 6223 on_window_drag_drop') - - widget.drag_get_data(context, context.list_targets()[-1], time) - - def on_window_drag_data_received(self, widget, context, x, y, data, info, time): @@ -6504,6 +6668,82 @@ class MainWin(Gtk.ApplicationWindow): ) + def on_video_index_check(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Check the right-clicked media data object. + + 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: + utils.debug_time('mwn 6390 on_video_index_check') + + if self.app_obj.current_manager_obj: + return self.app_obj.system_error( + 218, + 'Callback request denied due to current conditions', + ) + + # Start a download operation + self.app_obj.download_manager_start(True, False, [media_data_obj] ) + + + def on_video_index_convert_container(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Converts a channel to a playlist, or a playlist to a channel. + + 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: + utils.debug_time('mwn 6391 on_video_index_convert_container') + + if self.app_obj.current_manager_obj: + return self.app_obj.system_error( + 240, + 'Callback request denied due to current conditions', + ) + + self.app_obj.convert_remote_container(media_data_obj) + + + def on_video_index_delete_container(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Deletes the channel, playlist or folder. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Channel, media.Playlist or media.Folder): + The clicked media data object + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 6418 on_video_index_delete_container') + + self.app_obj.delete_container(media_data_obj) + + def on_video_index_dl_disable(self, menu_item, media_data_obj): """Called from a callback in self.video_index_popup_menu(). @@ -6536,55 +6776,6 @@ class MainWin(Gtk.ApplicationWindow): 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(). - - Check the right-clicked media data object. - - 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: - utils.debug_time('mwn 6390 on_video_index_check') - - if self.app_obj.current_manager_obj: - return self.app_obj.system_error( - 218, - 'Callback request denied due to current conditions', - ) - - # Start a download operation - self.app_obj.download_manager_start(True, False, [media_data_obj] ) - - - def on_video_index_delete_container(self, menu_item, media_data_obj): - - """Called from a callback in self.video_index_popup_menu(). - - Deletes the channel, playlist or folder. - - Args: - - menu_item (Gtk.MenuItem): The clicked menu item - - media_data_obj (media.Channel, media.Playlist or media.Folder): - The clicked media data object - - """ - - if DEBUG_FUNC_FLAG: - utils.debug_time('mwn 6418 on_video_index_delete_container') - - self.app_obj.delete_container(media_data_obj) - - def on_video_index_download(self, menu_item, media_data_obj): """Called from a callback in self.video_index_popup_menu(). @@ -7464,6 +7655,30 @@ class MainWin(Gtk.ApplicationWindow): config.ChannelPlaylistEditWin(self.app_obj, media_data_obj) + def on_video_index_show_system_cmd(self, menu_item, media_data_obj): + + """Called from a callback in self.video_index_popup_menu(). + + Opens a dialogue window to show the system command that would be used + to download the clicked channel/playlist/folder. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Video): The clicked video object + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 7288 on_video_index_show_system_cmd') + + # Show the dialogue window + dialogue_win = SystemCmdDialogue(self, media_data_obj) + dialogue_win.run() + dialogue_win.destroy() + + def on_video_catalogue_apply_options(self, menu_item, media_data_obj): """Called from a callback in self.video_catalogue_popup_menu(). @@ -7958,6 +8173,30 @@ class MainWin(Gtk.ApplicationWindow): entry.set_text(str(self.catalogue_page_size)) + def on_video_catalogue_show_system_cmd(self, menu_item, media_data_obj): + + """Called from a callback in self.video_catalogue_popup_menu(). + + Opens a dialogue window to show the system command that would be used + to download the clicked video. + + Args: + + menu_item (Gtk.MenuItem): The clicked menu item + + media_data_obj (media.Video): The clicked video object + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 7811 on_video_catalogue_show_system_cmd') + + # Show the dialogue window + dialogue_win = SystemCmdDialogue(self, media_data_obj) + dialogue_win.run() + dialogue_win.destroy() + + def on_video_catalogue_show_properties(self, menu_item, media_data_obj): """Called from a callback in self.video_catalogue_popup_menu(). @@ -7973,7 +8212,7 @@ class MainWin(Gtk.ApplicationWindow): """ if DEBUG_FUNC_FLAG: - utils.debug_time('mwn 7811 on_video_catalogue_show_properties') + utils.debug_time('mwn 7812 on_video_catalogue_show_properties') if self.app_obj.current_manager_obj: return self.app_obj.system_error( @@ -8476,6 +8715,7 @@ class MainWin(Gtk.ApplicationWindow): self.progress_list_popup_menu( event, self.progress_list_liststore[iter][0], + self.progress_list_liststore[iter][1], ) @@ -8602,7 +8842,7 @@ class MainWin(Gtk.ApplicationWindow): self.progress_list_liststore.set( self.progress_list_liststore.get_iter(tree_path), - 1, + 2, self.pixbuf_dict['arrow_down_small'], ) @@ -8646,11 +8886,60 @@ class MainWin(Gtk.ApplicationWindow): self.progress_list_liststore.set( self.progress_list_liststore.get_iter(tree_path), - 1, + 2, self.pixbuf_dict['arrow_up_small'], ) + def on_progress_list_watch_website(self, menu_item, media_data_obj): + + """Called from a callback in self.progress_list_popup_menu(). + + Opens the clicked video's source URL in a web browser. + + Args: + + menu_item (Gtk.MenuItem): The menu item that was clicked + + media_data_obj (media.Video): The corresponding media data object + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 8463 on_progress_list_watch_website') + + if isinstance(media_data_obj, media.Video) \ + and media_data_obj.source: + + utils.open_file(media_data_obj.source) + + + def on_progress_list_watch_hooktube(self, menu_item, media_data_obj, + mod_source): + + """Called from a callback in self.progress_list_popup_menu(). + + Opens the clicked video, which is a YouTube video, on the HookTube + website. + + Args: + + menu_item (Gtk.MenuItem): The menu item that was clicked + + media_data_obj (media.Video): The corresponding media data object + + mod_source (str): The video's source URL, already converted to + the HookTube equivalent + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 8464 on_progress_list_watch_hooktube') + + if isinstance(media_data_obj, media.Video) and mod_source: + utils.open_file(mod_source) + + def on_results_list_right_click(self, treeview, event): """Called from callback in self.setup_progress_tab(). @@ -8862,6 +9151,42 @@ class MainWin(Gtk.ApplicationWindow): ) + def on_show_error_checkbutton_changed(self, checkbutton): + + """Called from callback in self.setup_errors_tab(). + + Toggles display of system error messages in the tab. + + Args: + + checkbutton (Gtk.CheckButton) - The clicked widget + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 8694 on_show_error_checkbutton_changed') + + self.app_obj.set_system_error_show_flag(checkbutton.get_active()) + + + def on_show_warning_checkbutton_changed(self, checkbutton): + + """Called from callback in self.setup_errors_tab(). + + Toggles display of system warning messages in the tab. + + Args: + + checkbutton (Gtk.CheckButton) - The clicked widget + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 8695 on_show_warning_checkbutton_changed') + + self.app_obj.set_system_warning_show_flag(checkbutton.get_active()) + + def on_errors_list_clear(self, button): """Called from callback in self.setup_errors_tab(). @@ -9159,7 +9484,7 @@ class SimpleCatalogueItem(object): self.name_label.set_markup( '' + \ - cgi.escape( + html.escape( utils.shorten_string( name, self.main_win_obj.long_string_max_len, @@ -9190,7 +9515,7 @@ class SimpleCatalogueItem(object): else: string = 'From folder \'' - string2 = cgi.escape( + string2 = html.escape( utils.shorten_string( self.video_obj.parent_obj.name, self.main_win_obj.medium_string_max_len, @@ -9599,7 +9924,7 @@ class ComplexCatalogueItem(object): self.name_label.set_markup( '' + \ - cgi.escape( + html.escape( utils.shorten_string( name, self.main_win_obj.medium_string_max_len, @@ -9693,7 +10018,7 @@ class ComplexCatalogueItem(object): if not self.expand_descrip_flag: - string = cgi.escape( + string = html.escape( utils.shorten_string( line_list[0], self.main_win_obj.long_string_max_len, @@ -9710,7 +10035,7 @@ class ComplexCatalogueItem(object): else: - descrip = cgi.escape(self.video_obj.descrip, quote=True) + descrip = html.escape(self.video_obj.descrip, quote=True) if len(line_list) > 1: self.descrip_label.set_markup( @@ -9733,7 +10058,7 @@ class ComplexCatalogueItem(object): else: string = 'From folder \'' - string += cgi.escape( + string += html.escape( utils.shorten_string( self.video_obj.parent_obj.name, self.main_win_obj.long_string_max_len, @@ -9752,7 +10077,7 @@ class ComplexCatalogueItem(object): else: - descrip = cgi.escape(self.video_obj.descrip, quote=True) + descrip = html.escape(self.video_obj.descrip, quote=True) self.descrip_label.set_markup( 'Less ' + string + '\n' + descrip \ + '\n', @@ -10451,13 +10776,33 @@ class AddVideoDialogue(Gtk.Dialog): label = Gtk.Label('Copy and paste the links to one or more videos') grid.attach(label, 0, 0, 2, 1) + if main_win_obj.app_obj.operation_convert_mode == 'channel': + text = 'Links containing multiple videos will be converted to' \ + + ' a channel' + + elif main_win_obj.app_obj.operation_convert_mode == 'playlist': + text = 'Links containing multiple videos will be converted to a' \ + + ' playlist' + + elif main_win_obj.app_obj.operation_convert_mode == 'multi': + text = 'Links containing multiple videos will be downloaded' \ + + ' separately' + + elif main_win_obj.app_obj.operation_convert_mode == 'disable': + text = 'Links containing multiple videos will not be downloaded' + + ' at all' + + label = Gtk.Label() + label.set_markup('' + text + '') + grid.attach(label, 0, 1, 2, 1) + frame = Gtk.Frame() - grid.attach(frame, 0, 1, 2, 1) + grid.attach(frame, 0, 2, 2, 1) scrolledwindow = Gtk.ScrolledWindow() frame.add(scrolledwindow) - # (Set enough vertical room for at least five URLs) - scrolledwindow.set_size_request(-1, 100) + # (Set enough vertical room for at several URLs) + scrolledwindow.set_size_request(-1, 150) textview = Gtk.TextView() scrolledwindow.add(textview) @@ -10468,22 +10813,22 @@ class AddVideoDialogue(Gtk.Dialog): self.textbuffer = textview.get_buffer() separator = Gtk.HSeparator() - grid.attach(separator, 0, 2, 2, 1) + grid.attach(separator, 0, 3, 2, 1) self.button = Gtk.RadioButton.new_with_label_from_widget( None, 'I want to download these videos automatically', ) - grid.attach(self.button, 0, 3, 2, 1) + grid.attach(self.button, 0, 4, 2, 1) self.button2 = Gtk.RadioButton.new_from_widget(self.button) self.button2.set_label( 'Don\'t download anything, just check the videos', ) - grid.attach(self.button2, 0, 4, 2, 1) + grid.attach(self.button2, 0, 5, 2, 1) separator2 = Gtk.HSeparator() - grid.attach(separator2, 0, 5, 2, 1) + grid.attach(separator2, 0, 6, 2, 1) # Prepare a list of folders to display in a combo. The list always # includes the system folders 'Unsorted Videos' and 'Temporary @@ -10523,10 +10868,10 @@ class AddVideoDialogue(Gtk.Dialog): self.parent_name = self.folder_list[0] label2 = Gtk.Label('Add the videos to this folder') - grid.attach(label2, 0, 6, 2, 1) + grid.attach(label2, 0, 7, 2, 1) box = Gtk.Box() - grid.attach(box, 0, 7, 1, 1) + grid.attach(box, 0, 8, 1, 1) box.set_border_width(main_win_obj.spacing_size) image = Gtk.Image() @@ -10538,7 +10883,7 @@ class AddVideoDialogue(Gtk.Dialog): listmodel.append([item]) combo = Gtk.ComboBox.new_with_model(listmodel) - grid.attach(combo, 1, 7, 1, 1) + grid.attach(combo, 1, 8, 1, 1) combo.set_hexpand(True) cell = Gtk.CellRendererText() @@ -12439,3 +12784,225 @@ class MountDriveDialogue(Gtk.Dialog): self.destroy() +class SystemCmdDialogue(Gtk.Dialog): + + """Python class handling a dialogue window that shows the user the system + command that would be used in a download operation for a particular + media.Video, media.Channel, media.Playlist or media.Folder object. + + Args: + + main_win_obj (mainwin.MainWin): The parent main window + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object in question + + """ + + + # Standard class methods + + + def __init__(self, main_win_obj, media_data_obj): + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 12787 __init__') + + Gtk.Dialog.__init__( + self, + 'Show system command', + main_win_obj, + Gtk.DialogFlags.DESTROY_WITH_PARENT, + (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) + grid_width = 3 + + if isinstance(media_data_obj, media.Video): + obj_type = 'Video' + elif 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( + utils.shorten_string( + obj_type + ': ' + media_data_obj.name, + 50, + ), + ) + grid.attach(label, 0, 0, grid_width, 1) + + frame = Gtk.Frame() + grid.attach(frame, 0, 1, grid_width, 1) + + scrolled = Gtk.ScrolledWindow() + frame.add(scrolled) + scrolled.set_size_request(400, 150) + + textview = Gtk.TextView() + scrolled.add(textview) + textview.set_wrap_mode(Gtk.WrapMode.WORD) + textview.set_hexpand(False) + textview.set_editable(False) + + # (Store various widgets as IVs, so the calling function can retrieve + # their contents) + self.main_win_obj = main_win_obj + self.textbuffer = textview.get_buffer() + # Initialise the textbuffer's contents + self.update_textbuffer(media_data_obj) + + button = Gtk.Button('Update') + grid.attach(button, 0, 2, 1, 1) + button.set_hexpand(True) + button.connect( + 'clicked', + self.on_update_clicked, + media_data_obj, + ) + + button2 = Gtk.Button('Copy to clipboard') + grid.attach(button2, 1, 2, 1, 1) + button2.set_hexpand(True) + button2.connect( + 'clicked', + self.on_copy_clicked, + media_data_obj, + ) + + separator = Gtk.HSeparator() + grid.attach(separator, 0, 3, 2, 1) + + # Display the dialogue window + self.show_all() + + + # Public class methods + + + def update_textbuffer(self, media_data_obj): + + """Called from self.__init__(). + + Initialises the specified textbuffer. + + Args: + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object whose system command is + displayed in this dialogue window + + Return values: + + A string containing the system command displayed, or an empty + string if the system command could not be generated + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 12891 update_textbuffer') + + # Get the options.OptionsManager object that applies to this media + # data object + # (The manager might be specified by obj itself, or it might be + # specified by obj's parent, or we might use the default + # options.OptionsManager) + options_obj = utils.get_options_manager( + self.main_win_obj.app_obj, + media_data_obj, + ) + + # Generate the list of download options for this media data object + options_parser_obj = options.OptionsParser(self.main_win_obj.app_obj) + options_list = options_parser_obj.parse(media_data_obj, options_obj) + + # Obtain the system command used to download this media data object + cmd_list = utils.generate_system_cmd( + self.main_win_obj.app_obj, + media_data_obj, + options_list, + ) + + # Display it in the textbuffer + if cmd_list: + char = ' ' + system_cmd = char.join(cmd_list) + + else: + system_cmd = '' + + + self.textbuffer.set_text(system_cmd) + return system_cmd + + + # (Callbacks) + + def on_update_clicked(self, button, media_data_obj): + + """Called from a callback in self.__init__(). + + Updates the contents of the textview. + + Args: + + button (Gtk.Button): The widget clicked + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object whose system command is + displayed in this dialogue window + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 12880 on_update_clicked') + + # Obtain the system command used to download this media data object, + # and display it in the textbuffer + self.update_textbuffer(media_data_obj) + + + def on_copy_clicked(self, button, media_data_obj): + + """Called from a callback in self.__init__(). + + Updates the contents of the textview, and copies the system command to + the clipboard. + + Args: + + button (Gtk.Button): The widget clicked + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object whose system command is + displayed in this dialogue window + + """ + + if DEBUG_FUNC_FLAG: + utils.debug_time('mwn 12914 on_copy_clicked') + + # Obtain the system command used to download this media data object, + # and display it in the textbuffer + system_cmd = self.update_textbuffer(media_data_obj) + + # Copy the system command to the clipboard + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(system_cmd, -1) + + + + + diff --git a/tartube/media.py b/tartube/media.py index ec6f67b..789293a 100755 --- a/tartube/media.py +++ b/tartube/media.py @@ -95,6 +95,11 @@ class GenericMedia(object): self.options_obj = options_obj + def set_parent_obj(self, parent_obj): + + self.parent_obj = parent_obj + + def set_warning(self, msg): # The media.Folder object has no error/warning IVs (and shouldn't @@ -333,6 +338,78 @@ class GenericContainer(GenericMedia): return None + def find_matching_video(self, app_obj, name): + + """Can be called by anything. + + Checks all of this object's child objects, looking for a media.Video + object with a matching name. + + Args: + + app_obj (mainapp.TartubeApp): The main application + + name (string): The name of the media.Video object to find + + Returns: + + The first matching media.Video object found, or None if no matching + videos are found. + + """ + + method = app_obj.match_method + first = app_obj.match_first_chars + ignore = app_obj.match_ignore_chars * -1 + + # Defend against two different of a name from the same video, one with + # punctuation marks stripped away, and double quotes converted to + # single quotes (thanks, YouTube!) by replacing those characters with + # whitespace + # (After extensive testing, this is the only regex sequence I could + # find that worked) + test_name = name[:] + + # Remove punctuation + test_name = re.sub(r'\W+', ' ', test_name, flags=re.UNICODE) + # Also need to replace underline characters + test_name = re.sub(r'[\_\s]+', ' ', test_name) + # Also need to remove leading/trailing whitespace, in case the original + # video name started/ended with a question mark or something like + # that + test_name = re.sub(r'^\s+', '', test_name) + test_name = re.sub(r'\s+$', '', test_name) + + for child_obj in self.child_list: + if isinstance(child_obj, Video): + + child_name = child_obj.name[:] + child_name = re.sub( + r'\W+', + ' ', + child_name, + flags=re.UNICODE, + ) + child_name = re.sub(r'[\_\s]+', ' ', child_name) + child_name = re.sub(r'^\s+', '', child_name) + child_name = re.sub(r'\s+$', '', child_name) + + if ( + method == 'exact_match' \ + and child_name == test_name + ) or ( + method == 'match_first' \ + and child_name[:first] == test_name[:first] + ) or ( + method == 'ignore_last' \ + and child_name[:ignore] == test_name[:ignore] + ): + return child_obj + + # No matches found + return None + + def get_depth(self): """Can be called by anything. @@ -737,11 +814,6 @@ class GenericContainer(GenericMedia): self.name = name - def set_parent_obj(self, parent_obj): - - self.parent_obj = parent_obj - - # Get accessors @@ -900,78 +972,6 @@ class GenericRemoteContainer(GenericContainer): return 0 - def find_matching_video(self, app_obj, name): - - """Can be called by anything. - - Checks all of this object's child objects, looking for a media.Video - object with a matching name. - - Args: - - app_obj (mainapp.TartubeApp): The main application - - name (string): The name of the media.Video object to find - - Returns: - - The first matching media.Video object found, or None if no matching - videos are found. - - """ - - method = app_obj.match_method - first = app_obj.match_first_chars - ignore = app_obj.match_ignore_chars * -1 - - # Defend against two different of a name from the same video, one with - # punctuation marks stripped away, and double quotes converted to - # single quotes (thanks, YouTube!) by replacing those characters with - # whitespace - # (After extensive testing, this is the only regex sequence I could - # find that worked) - test_name = name[:] - - # Remove punctuation - test_name = re.sub(r'\W+', ' ', test_name, flags=re.UNICODE) - # Also need to replace underline characters - test_name = re.sub(r'[\_\s]+', ' ', test_name) - # Also need to remove leading/trailing whitespace, in case the original - # video name started/ended with a question mark or something like - # that - test_name = re.sub(r'^\s+', '', test_name) - test_name = re.sub(r'\s+$', '', test_name) - - for child_obj in self.child_list: - if isinstance(child_obj, Video): - - child_name = child_obj.name[:] - child_name = re.sub( - r'\W+', - ' ', - child_name, - flags=re.UNICODE, - ) - child_name = re.sub(r'[\_\s]+', ' ', child_name) - child_name = re.sub(r'^\s+', '', child_name) - child_name = re.sub(r'\s+$', '', child_name) - - if ( - method == 'exact_match' \ - and child_name == test_name - ) or ( - method == 'match_first' \ - and child_name[:first] == test_name[:first] - ) or ( - method == 'ignore_last' \ - and child_name[:ignore] == test_name[:ignore] - ): - return child_obj - - # No matches found - return None - - def sort_children(self): """Can be called by anything. For example, called by self.add_child(). @@ -1000,6 +1000,36 @@ class GenericRemoteContainer(GenericContainer): # Set accessors + def clone_properties(self, other_obj): + + """Called by mainapp.TartubeApp.convert_remote_container() only. + + Copies properties from a media data object (about to be deleted) to + this media data object. + + Some properties are handled by the calling function; this function + handles the rest of them. + + Args: + + other_obj (media.Channel, media.Playlist): The object whose + properties should be copied + + """ + + self.options_obj = other_obj.options_obj + self.nickname = other_obj.nickname + self.source = other_obj.source + self.dl_sim_flag = other_obj.dl_sim_flag + self.dl_disable_flag = other_obj.dl_disable_flag + self.fav_flag = other_obj.fav_flag + self.new_count = other_obj.new_count + self.fav_count = other_obj.fav_count + self.dl_count = other_obj.dl_count + self.error_list = other_obj.error_list.copy() + self.warning_list = other_obj.warning_list.copy() + + def set_source(self, source): self.source = source @@ -1106,9 +1136,12 @@ class Video(GenericMedia): self.receive_time = None # The video's duration (in integer seconds) self.duration = None - # For videos in a playlist (i.e. a media.Video object whose parent is - # a media.Playlist object), the video's index in the playlist. For - # all other situations, the value remains as None + # For videos in a channel or playlist (i.e. a media.Video object whose + # parent is a media.Channel or media.Playlist object), the video's + # index in the channel/playlist. (The server supplies an index even + # for a channel, and the user might want to convert a channel to a + # playlist) + # For videos whose parent is a media.Folder, the value remains as None self.index = None # Video description. A string of any length, containing newline diff --git a/tartube/options.py b/tartube/options.py index 8389ec7..e939559 100755 --- a/tartube/options.py +++ b/tartube/options.py @@ -571,10 +571,8 @@ class OptionsManager(object): class OptionsParser(object): - """Called by downloads.DownloadManager.__init__(). - - Each download operation, handled by the downloads.DownloadManager, creates - an instance of this class. + """Called by downloads.DownloadManager.__init__() and by + mainwin.SystemCmdDialogue.update_textbuffer(). This object converts the download options specified by an options.OptionsManager object into a list of youtube-dl command line @@ -582,8 +580,7 @@ class OptionsParser(object): Args: - download_manager_obj (downloads.DownloadManager) - The parent - download manager object + app_obj (mainapp.TartubeApp): The main application """ @@ -591,12 +588,12 @@ class OptionsParser(object): # Standard class methods - def __init__(self, download_manager_obj): + def __init__(self, app_obj): # IV list - class objects # ----------------------- - # The parent downloads.DownloadManager object - self.download_manager_obj = download_manager_obj + # The main application + self.app_obj = app_obj # IV list - other @@ -812,7 +809,7 @@ class OptionsParser(object): # Public class methods - def parse(self, download_item_obj, options_dict): + def parse(self, media_data_obj, options_manager_obj): """Called by downloads.DownloadWorker.prepare_download(). @@ -822,11 +819,11 @@ class OptionsParser(object): Args: - download_item_obj (downloads.DownloadItem) - The object handling - the download + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object being downloaded - options_dict (dict): Python dictionary containing download options; - taken from options.OptionsManager.options_dict + options_manager_obj (options.OptionsManager): The object containing + the download options for this media data object Returns: @@ -838,11 +835,11 @@ class OptionsParser(object): options_list = ['--newline'] # Create a copy of the dictionary... - copy_dict = options_dict.copy() + copy_dict = options_manager_obj.options_dict.copy() # ...then modify various values in the copy. Set the 'save_path' option - self.build_save_path(download_item_obj, copy_dict) + self.build_save_path(media_data_obj, copy_dict) # Set the 'video_format' option - self.build_video_format(download_item_obj, copy_dict) + self.build_video_format(copy_dict) # Set the 'min_filesize' and 'max_filesize' options self.build_file_sizes(copy_dict) # Set the 'limit_rate' option @@ -851,12 +848,9 @@ class OptionsParser(object): # Reset the 'playlist_start', 'playlist_end' and 'max_downloads' # options if we're not downloading a video in a playlist if ( - isinstance(download_item_obj.media_data_obj, media.Video) \ - and not isinstance( - download_item_obj.media_data_obj.parent_obj, - media.Playlist, - ) - ) or not isinstance(download_item_obj.media_data_obj, media.Playlist): + isinstance(media_data_obj, media.Video) \ + and not isinstance(media_data_obj.parent_obj, media.Playlist) + ) or not isinstance(media_data_obj, media.Playlist): copy_dict['playlist_start'] = 1 copy_dict['playlist_end'] = 0 copy_dict['max_downloads'] = 0 @@ -998,18 +992,19 @@ class OptionsParser(object): """ - # Import the main app (for convenience) - app_obj = self.download_manager_obj.app_obj - # Set the bandwidth limit (e.g. '50K') - if app_obj.bandwidth_apply_flag: + if self.app_obj.bandwidth_apply_flag: # The bandwidth limit is divided equally between the workers - limit = int(app_obj.bandwidth_default / app_obj.num_worker_default) + limit = int( + self.app_obj.bandwidth_default + / self.app_obj.num_worker_default + ) + copy_dict['limit_rate'] = str(limit) + 'K' - def build_save_path(self, download_item_obj, copy_dict): + def build_save_path(self, media_data_obj, copy_dict): """Called by self.parse(). @@ -1018,16 +1013,14 @@ class OptionsParser(object): Args: - download_item_obj (downloads.DownloadItem) - The object handling - the download + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object being downloaded copy_dict (dict): Copy of the original options dictionary. """ # 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 override_name = copy_dict['use_fixed_folder'] if not isinstance(media_data_obj, media.Video) \ @@ -1042,15 +1035,9 @@ class OptionsParser(object): else: if isinstance(media_data_obj, media.Video): - save_path = media_data_obj.parent_obj.get_dir( - self.download_manager_obj.app_obj - ) - + save_path = media_data_obj.parent_obj.get_dir(self.app_obj) else: - save_path = media_data_obj.get_dir( - self.download_manager_obj.app_obj - ) - + save_path = media_data_obj.get_dir(self.app_obj) # Set the youtube-dl output template for the video's file template = formats.FILE_OUTPUT_CONVERT_DICT[copy_dict['output_format']] @@ -1063,7 +1050,7 @@ class OptionsParser(object): ) - def build_video_format(self, download_item_obj, copy_dict): + def build_video_format(self, copy_dict): """Called by self.parse(). @@ -1072,9 +1059,6 @@ class OptionsParser(object): Args: - download_item_obj (downloads.DownloadItem) - The object handling - the download - copy_dict (dict): Copy of the original options dictionary. """ @@ -1089,18 +1073,17 @@ class OptionsParser(object): # extractor codes are ignored resolution_dict = formats.VIDEO_RESOLUTION_DICT.copy() fps_dict = formats.VIDEO_FPS_DICT.copy() - app_obj = self.download_manager_obj.app_obj # If the progressive scan resolution is specified, it overrides all # other video format options height = None fps = None - if app_obj.video_res_apply_flag: - height = resolution_dict[app_obj.video_res_default] + if self.app_obj.video_res_apply_flag: + height = resolution_dict[self.app_obj.video_res_default] # (Currently, formats.VIDEO_FPS_DICT only lists formats with 60fps) - if app_obj.video_res_default in fps_dict: - fps = fps_dict[app_obj.video_res_default] + if self.app_obj.video_res_default in fps_dict: + fps = fps_dict[self.app_obj.video_res_default] elif copy_dict['video_format'] in resolution_dict: height = resolution_dict[copy_dict['video_format']] diff --git a/tartube/tartube b/tartube/tartube index 887236a..ce4538b 100755 --- a/tartube/tartube +++ b/tartube/tartube @@ -35,8 +35,8 @@ import mainapp # 'Global' variables __packagename__ = 'tartube' -__version__ = '1.3.053' -__date__ = '23 Jan 2020' +__version__ = '1.3.077' +__date__ = '26 Jan 2020' __copyright__ = 'Copyright \xa9 2019-2020 A S Lewis' __license__ = """ Copyright \xc2\xa9 2019-2020 A S Lewis. diff --git a/tartube/tartube_debian b/tartube/tartube_debian index 7af1996..97834f3 100755 --- a/tartube/tartube_debian +++ b/tartube/tartube_debian @@ -35,8 +35,8 @@ import mainapp # 'Global' variables __packagename__ = 'tartube' -__version__ = '1.3.053' -__date__ = '23 Jan 2020' +__version__ = '1.3.077' +__date__ = '26 Jan 2020' __copyright__ = 'Copyright \xa9 2019-2020 A S Lewis' __license__ = """ Copyright \xc2\xa9 2019-2020 A S Lewis. diff --git a/tartube/updates.py b/tartube/updates.py index a4bb22e..11ec8c6 100755 --- a/tartube/updates.py +++ b/tartube/updates.py @@ -157,13 +157,20 @@ class UpdateManager(threading.Thread): # Create a new child process to install either the 64-bit or 32-bit # version of FFmpeg, as appropriate if sys.maxsize <= 2147483647: - self.create_child_process( - ['pacman', '-S', 'mingw-w64-i686-ffmpeg', '--noconfirm'], - ) + binary = 'mingw-w64-i686-ffmpeg' else: - self.create_child_process( - ['pacman', '-S', 'mingw-w64-x86_64-ffmpeg', '--noconfirm'], - ) + binary = 'mingw-w64-x86_64-ffmpeg' + + self.create_child_process( + ['pacman', '-S', binary, '--noconfirm'], + ) + + # Show the system command in the Output Tab + space = ' ' + self.app_obj.main_win_obj.output_tab_write_system_cmd( + 1, + space.join( ['pacman', '-S', binary, '--noconfirm'] ), + ) # So that we can read from the child process STDOUT and STDERR, attach # a file descriptor to the PipeReader objects @@ -276,6 +283,13 @@ class UpdateManager(threading.Thread): # Create a new child process using that command self.create_child_process(cmd_list) + # Show the system command in the Output Tab + space = ' ' + self.app_obj.main_win_obj.output_tab_write_system_cmd( + 1, + space.join(cmd_list), + ) + # So that we can read from the child process STDOUT and STDERR, attach # a file descriptor to the PipeReader objects if self.child_process is not None: diff --git a/tartube/utils.py b/tartube/utils.py index 4863d43..8d9b39f 100755 --- a/tartube/utils.py +++ b/tartube/utils.py @@ -40,6 +40,7 @@ import textwrap # Import our modules import formats import mainapp +import media # Functions @@ -471,6 +472,75 @@ def format_bytes(num_bytes): return "%.2f%s" % (output_value, suffix) +def generate_system_cmd(app_obj, media_data_obj, options_list, +dl_sim_flag=False): + + """Called by downloads.VideoDownloader.do_download() and + mainwin.SystemCmdDialogue.update_textbuffer(). + + Based on YoutubeDLDownloader._get_cmd(). + + Prepare the system command that instructs youtube-dl to download the + specified media data object. + + Args: + + app_obj (mainapp.TartubeApp): The main application + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): The media data object to be downloaded + + options_list (list): A list of download options generated by a call to + options.OptionsParser.parse() + + dl_sim_flag (bool): True if a simulated download is to take place, + False if a real download is to take place + + Returns: + + Python list that contains the system command to execute and its + arguments + + """ + + # Simulate the download, rather than actually downloading videos, if + # required + if 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 + elif app_obj.allow_ytdl_archive_flag: + + # (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 app_obj.ytdl_write_verbose_flag: + options_list.append('--verbose') + + # Supply youtube-dl with the path to the ffmpeg/avconv binary, if the + # user has provided one + if app_obj.ffmpeg_path is not None: + options_list.append('--ffmpeg-location') + options_list.append('"' + app_obj.ffmpeg_path + '"') + + # Set the list + cmd_list = [app_obj.ytdl_path] + options_list + [media_data_obj.source] + + return cmd_list + + def get_encoding(): """Based on the get_encoding() function in youtube-dl-gui. Now called @@ -491,6 +561,40 @@ def get_encoding(): return encoding +def get_options_manager(app_obj, media_data_obj): + + """Can be called by anything, and is then called by this function + recursively. + + Fetches the options.OptionsManager which applies to the specified media + data object. + + The media data object might specify its own options.OptionsManager, or + we might have to use the parent's, or the parent's parent's (and so + on). As a last resort, use General Options Manager. + + Args: + + app_obj (mainapp.TartubeApp): The main application + + media_data_obj (media.Video, media.Channel, media.Playlist, + media.Folder): A media data object + + Returns: + + The options.OptionsManager object that applies to the specified + media data object + + """ + + if media_data_obj.options_obj: + return media_data_obj.options_obj + elif media_data_obj.parent_obj: + return get_options_manager(app_obj, media_data_obj.parent_obj) + else: + return app_obj.general_options_obj + + def open_file(uri): """Can be called by anything. @@ -505,9 +609,6 @@ def open_file(uri): """ if sys.platform == "win32": - # v1.2.052. If the video file's filename contains an ampersand, MSWin - # is passed a string containing & - so we need to strip that - uri = re.sub('\&', '&', uri) os.startfile(uri) else: opener ="open" if sys.platform == "darwin" else "xdg-open"