From 08723760c1c61cd1a22bdbe5602b191c5ef99ff0 Mon Sep 17 00:00:00 2001 From: A S Lewis Date: Sun, 26 Jan 2020 16:26:57 +0000 Subject: [PATCH] Updated to v1.3.077 --- CHANGES | 67 +++ README.rst | 119 ++--- nsis/tartube_install_32bit.nsi | 6 +- nsis/tartube_install_64bit.nsi | 6 +- screenshots/example4.png | Bin 20205 -> 23580 bytes setup.py | 2 +- tartube/config.py | 662 +++++++++++++++++------- tartube/downloads.py | 497 ++++++++++++------ tartube/mainapp.py | 357 ++++++++++++- tartube/mainwin.py | 913 ++++++++++++++++++++++++++------- tartube/media.py | 193 ++++--- tartube/options.py | 83 ++- tartube/tartube | 4 +- tartube/tartube_debian | 4 +- tartube/updates.py | 26 +- tartube/utils.py | 107 +++- 16 files changed, 2307 insertions(+), 739 deletions(-) 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 a5e09d8dcf01e2761b51dcb7ee3d4a41c0edd704..37cd7a0912964cdb1f94c0ef1bae34575ec87d98 100644 GIT binary patch literal 23580 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�~r#vbdDkOOESv$il6W zp8{lz{1n%%ddn)iP~&{^AJ1r=*IhTXLmH8hKoIdhor|*R>@1V=RI$3`pE;=mB^W`?z_~mxuMJS9tbzOUb-kvcDX908hY`$?M0#`uz4~TqXpE=sNdIlQxXG7& zD4$cwdY)&2yHmvKa;_H+8FUnq;IBGWjAqP_w5E{Zw0zG}xp6#X99qht;tX&Ll1dZAN`3oHv z6EJ`Ig8^IgBUP$?d+{C1XPnK;uPsv=nGK?NAd*ztor3Sr;vAMsd{Ky^kN57-W4IHp=`Zma9tr_kI|c3ErL<*J5^_gu~sQ8 zQ(U3~O6v;ke!DcizSP93uUNJnjxhoDL~9vPa*T3uKmS<2^A!%cK*Z=EX6UUa4hoO+ zjeIUPs{oB6GjjZ`@$0=@5B6@H8-VhQEjQ#Jr(w9AEVXVvcV+As0S1S-$*i(9o&F77 zd`uX_647gGntZ(&7oj_opj-)2JS`Dl@Hrv5|Eq*Q<{c*`%#2W(c-dqsIL}on1Pt@_ zlw9e^yx9}(X^c)sIrhRx`uwH}r?aWtf@EncZ;r89;Zg4GOF!qq!~%UO zs?xML9f_lXsV^eBxHC`#JG@;fUBP}m?#6FJDU{rjM`Q_uRO0y~8C^ZamUf|cUnMOp z04+t+i`tW0z?I%MN}8HmF_N$LxCZ;?zyLO<6ZhLgPauaHEDT63Y<+}X+lss0AWR*l z>6W`&-tg!Vhe+A_H+xw0_RO>ULyiPFD-mWFqI5xk*Z_Ip7+(W*wJw|g@&3TVyN78@ zM3Ktx)ZWi7BZ)OWr=C+UiL1@~(Z{8w)$AAUx4(BT8njuORd{#$=Sz!7-;|(0_*pc~ z1Ys8hhu&=aIJYWqbIVPU$FLiyJIT*mFoqrdesit=Uj%iW7|9i^Nv-m|7-!_?jL)oX zV>LMKy%4?t--kC`he_!FDQzUc!TDFN{$JAe|3{P4YmoxHDCg%V{Q2|ei{0dT?b&3LfIYCvv4qA|9fbOyKc}F8`SLSff#vq}5-^yf zT(^OSnHhtJr(vnkC@~OlcYMJlRFZ?hPe9R~>+7;3FMhDLG<{-AFhAh3jt0um?iX@| zi0ZceOKNG^>Q%yK&`ivh$x^;A(v5d|p^S7=r^?5M%{YWuCip9N`;`U zKRpi7`#!t<%>iFSf=Q{~rMjUa_$AO+yEQ_3dU_=yDAT*&%x+k`D&@9*+@YG3B_xnM zU!Ze*2&^W(PdSJy1T&d^;Q6hg{uvl#xkeoBc;JEGddVW&(96zJfX0Jax0zpBG?H9z z`ieZdz1RP6?qxBvM49RRM4yx%sqY1l@bt1gvt!8lnTua$>iE8 z<{Pb>dueD8w=wGtu<63wE3gAc{b-y`&D4MWovghESNU$p%4w&Hb>H&p)tOXEE%k2ZlE=@@=2EA>4+4TL3nO|8^J*f?g^1>h zJw9>YXa;i%`=9cxIFu90PR>cRvVD&Gf$71@mR7~o>S8jGv9in3wu}r*Lrts=&S!CbwKX$j{pfhjS_|0F5gX=OLd;6f?HJqc zX{(O#Z)S&mb3W2mlfctlnT zy%MC$1p%|2t$WbmLKJ<|Xg4(c8{KF#P%}g^Hh(-s@7AI zJ5wAAxOO8_cW`+-_Y-{~{LHby#xiwNmPYHea@1lSJI&)-A&qi|@>FQHQkD6uY0Z1r zd1`Sh>v3Bj9Frp0dU)&Mk@Xe%9U=N#IT{=X2M3Q4++;bLmX6ch-5r(nlV61p&`_kM zX2PYRvUP1}fk9w}Wy09m(YcqUXB= z$uu?d4|bZVwF{u}7ZH}%rokU6(JL((diuH!ET~NuH$&w8B6)CmSVpSpL{X5<*rb&r z!AK`9IgQmIBjD0plV)}DRRah9cvCU1E|jX`UdLAO>S@}FGd3aN*Wk=qp$bi0XjvH( zl$E#yUj~d-1s}E^xgQQbf=}Zp?vScG%oL5>j#qLnT{6TODpQmSJrIP!P(B%NzoUxX ztMc+yhI)-D=PxX=%#~ES_p7dnT=@vKx3?cJH$=~s>n=b3T{UB`c3cw%0D+T7N74i~ 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 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"