Update to v2.3.549

This commit is contained in:
A S Lewis 2022-04-09 09:52:25 +01:00
parent 8493d8b6d1
commit e0a8fe1d6b
26 changed files with 14589 additions and 11991 deletions

97
CHANGES
View File

@ -1,3 +1,100 @@
v2.3.549 (9 Apr 2022)
-------------------------------------------------------------------------------
MAJOR NEW FEATURES
- Each channel/playlist/folder in the Videos tab now has a checkbutton. You
can select one or more items, if you want to check/download just those
items. Click the buttons in the bottom-left corner to do that. Even when
items are selected, you can still download everything using the main menu
or from the toolbar. The checkbuttons can be hidden, if you don't want
them: click Edit > System preferences... > Videos, and de-select 'Allow
each row to be marked for checking/downloading' (Git #379)
- New Drag and Drop tab. The tab is divided into a grid. Each zone represents a
set of download options. Drag and drop a video from your browser (or a
text URL) onto one of these zones, and it is added to the Classic Mode tab,
ready for download using the specified download options. Because of Gtk
issues, drag and drop from a browser does not work well on MS Windows.
All users will receive a new set of download options called 'mp3', unless
they already have one with that name
- The layout of the Errors/Warnings tab has been redesigned. It is now fully
searchable. All the checkbuttons like 'Show Tartube errors' are now
applied immediately, and are reversible. (Previously, they only applied to
errors/warnings received in the future)
MINOR NEW FEATURES
- The Classic Mode tab has a new button at the bottom of the tab. When
selected, youtube-dl creates an archive file. This is not recommended in
most cases; it might be useful for downloading large channels/playlists, if
the download might need to be resumed after an interruption (Git #285)
- In the download options windows (e.g. Edit > General download options...),
when advanced options are visible, the options were previously in a
separate yt-dlp tab have been merged into the other tabs. This is because
yt-dlp is now the default downloader for nearly all users
- The layouts in the preferences window and several other edit windows have
been improved (includes Git #360)
- Custom downloads can now skip broadcasting livestreams, or livestreams that
have already finished. They can also skip all videos EXCEPT current/former
livestreams. New icons in the Videos tab highlights which videos are
former livestreams; one for downloaded videos, one for checked videos
(Git #358)
- Tartube can now open in the system tray. See the new setting in Edit >
System preferences > Windows > Tray (Git #365)
- Tidy operations can now remove videos without URLs, or duplicate videos, from
the Tartube database
- In the video properties window, Timestamps tab, you can now update the list
of timestamps manually by clicking the new 'Reset list using copied text'
button, and then copy-pasting text into the dialogue window (for example,
from the video's description) (Git #330)
- Minor changes to the layout of the main window's menu
MAJOR FIXES
- The dialogue windows for adding channels, playlists and folder dialogues
accepted invalid characters and names. Fixed to prevent illegel directory/
folder names on MS Windows, Linux and MacOS (Git #335)
- A 'blocked' video (e.g. an age-restricted video from YouTube) remains
invisible in the Videos tab, by default, even after it is successfully
checked or downloaded (for example, after the user has supplied login
credentials). Fixed
- Errors/warnings assigned by Tartube to an individual video were not cleared
when the video was checked/downloaded without further errors/warnings.
Fixed
- Fixed more crashes due to Gtk issues
- The download options window could not be opened, when the
'--cookies-from-browser' option was set with KEYRING and PROFILE
components. Fixed (Git #394)
- Fix python error when starting Tartube (Git #366)
- Fixed video duplication error when downloading videos that already exist on
the filesystem
- After right-clicking a video and selecting 'Special > Download video clips'
or 'Special > Remove video slices', the dialogue windows had several
serious issues. Fixed them
- In the Classic Mode tab, when a channel/playlist download is interrupted
before the download is complete, the URL is not remembered for the next
session. Fixed (Git #285)
MINOR FIXES
- When checking/downloading produces no new videos, a 'newbie' dialogue is
displayed pointing the user to possible solutions. The dialogue was
displayed even when youtube-dl encountered videos that had already been
downloaded, or that were registered in youtube-dl's archive file. This no
longer happens (Git #368)
- In the preferences window, Windows > Videos tab, the 'Show today and
yesterday as the date, when possible' setting could not be disabled, once
enabled. Fixed
- In the edit window for custom downloads, in the Slices tab, the toggle
buttons were all broken. Fixed them
- After right-clicking a folder and selecting 'Folder contents > All contents >
Empty folder', the text of the dialogue window was gibberish. For this
procedure and related ones, improved the dialogue layout and tightened up
the code. When emptying videos/channels/playlists/folders from the
database (but not removing files on the filesystem), added a new
confirmation dialogue (Git #332)
- The video properties window did not show clip/chapter titles. Fixed
(Git #330)
- Improved wording in the 'Add channel' dialogue, specifically to remove the
word 'automatically'. Updated the video/playlist/folder dialogues too
(Git #277)
v2.3.484 (31 Mar 2022)
-------------------------------------------------------------------------------

View File

@ -59,16 +59,16 @@ For a full list of new features and fixes, see `recent changes <CHANGES>`__.
3 Downloads
===========
Stable release: **v2.3.484 (31 Mar 2022)**
Stable release: **v2.3.549 (9 Apr 2022)**
Development release: **v2.3.518 (5 Apr 2022)**
Development release: **v2.3.549 (9 Apr 2022)**
Official packages (also available from the `Github release page <https://github.com/axcore/tartube/releases>`__):
- `MS Windows (64-bit) installer <https://sourceforge.net/projects/tartube/files/v2.3.484/install-tartube-2.3.484-64bit.exe/download>`__ and `portable edition <https://sourceforge.net/projects/tartube/files/v2.3.484/tartube-2.3.484-64bit-portable.zip/download>`__ from Sourceforge
- `MS Windows (64-bit) installer <https://sourceforge.net/projects/tartube/files/v2.3.549/install-tartube-2.3.549-64bit.exe/download>`__ and `portable edition <https://sourceforge.net/projects/tartube/files/v2.3.549/tartube-2.3.549-64bit-portable.zip/download>`__ from Sourceforge
- Tartube is no longer supported on MS Windows (32-bit) - see `7.23 Doesn't work on 32-bit Windows`_
- `DEB package (for Debian-based distros, e.g. Ubuntu, Linux Mint) <https://sourceforge.net/projects/tartube/files/v2.3.484/python3-tartube_2.3.484.deb/download>`__ from Sourceforge
- `RPM package (for RHEL-based distros, e.g. Fedora) <https://sourceforge.net/projects/tartube/files/v2.3.484/tartube-2.3.484.rpm/download>`__ from Sourceforge
- `DEB package (for Debian-based distros, e.g. Ubuntu, Linux Mint) <https://sourceforge.net/projects/tartube/files/v2.3.549/python3-tartube_2.3.549.deb/download>`__ from Sourceforge
- `RPM package (for RHEL-based distros, e.g. Fedora) <https://sourceforge.net/projects/tartube/files/v2.3.549/tartube-2.3.549.rpm/download>`__ from Sourceforge
Official 'Strict' packages:
@ -649,7 +649,7 @@ Secondly, you could import a text file contaiing a list of channels/playlists. Y
... where **<url>** is the web address of the channel/playlist. (Leave out the diamond brackets.)
When you're ready, click **Media > Import into database > Plain text export file...**
When you're ready, click **Media > Export/import > Import into database > Plain text export file...**
6.8.2 Replacing generic channel/playlist names
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -1301,7 +1301,7 @@ You can export the contents of **Tartube**'s database and, at any time in the fu
It is important to note that *only a list of videos, channels, playlists and folders are exported*. The videos themselves are not exported, and neither are any thumbnail, description or metadata files.
- Click **Media > Export from database...**
- Click **Media > Export/import > Export from database...**
- In the dialogue window, choose what you want to export
- If you want a list that you can edit in an ordinary text editor, select the **Export as plain text** option
- If you want a list that yuu can edit in a spreadsheet, select the **Export as CSV** option
@ -1312,7 +1312,7 @@ It is safe to share this export file with other people. It doesn't contain any p
This is how to import the data into a different **Tartube** database.
- Click **Media > Import into database...**
- Click **Media > Export/import > Import into 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
@ -1992,7 +1992,7 @@ The export can then be re-imported into your current database in the normal way
A: Earlier versions of **Tartube** did in fact introduce occasional blips into the database. It's possible (though unlikely) that some blips still exist, despite the best efforts of the authors. If you really want to rebuild the database from scratch, this is how to do it.
Firstly, click **Media > Export from database...**. In the dialogue window, it's not necessary to select the button **Include lists of videos**. Click the **OK** button. Let Tartube create the backup file. You now have a backup of the names and URLs for every channel/playlist you've added.
Firstly, click **Media > Export/import > Export from database...**. In the dialogue window, it's not necessary to select the button **Include lists of videos**. Click the **OK** button. Let Tartube create the backup file. You now have a backup of the names and URLs for every channel/playlist you've added.
Next, shut down **Tartube**.
@ -2000,7 +2000,7 @@ Next, shut down **Tartube**.
Now you can restart **Tartube**. **Tartube** will create a brand new database file.
Click **Media > Import into database > JSON export file...**. Import the file you created moments ago.
Click **Media > Export/import > Import into database > JSON export file...**. Import the file you created moments ago.
All the channels/playlists should now be visible in the main window. Click the **Check All** button in the bottom-left corner and wait for it to finish.
@ -2117,9 +2117,9 @@ Tartube can merge a video and audio file together, long after they have been dow
A: In the main window's toolbar, click the **Hide (most) system folders** button (a red folder)
A: In the main menu, click **Media > Hide (most) system folders**
A: In the main menu, click **Media > Show/hide > Hide (most) system folders**
A: Right-click the folders you don't want to see, and select **Folder actions > Hide folder**. To reverse this step, in the main menu click **Media > Show hidden folders**
A: Right-click the folders you don't want to see, and select **Folder actions > Hide folder**. To reverse this step, in the main menu click **Media > Show/hide > Show hidden folders**
A: In the main menu, click **Edit > System preferences... > Windows > Videos**, and click **Show smaller icons in the Video Index** to select it

View File

@ -1 +1 @@
2.3.518
2.3.549

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
# Tartube v2.3.518 installer script for MS Windows
# Tartube v2.3.549 installer script for MS Windows
#
# Copyright (C) 2019-2022 A S Lewis
#
@ -294,7 +294,7 @@
;Name and file
Name "Tartube"
OutFile "install-tartube-2.3.518-64bit.exe"
OutFile "install-tartube-2.3.549-64bit.exe"
;Default installation folder
InstallDir "$LOCALAPPDATA\Tartube"
@ -397,7 +397,7 @@ Section "Tartube" SecClient
# "Publisher" "A S Lewis"
# WriteRegStr HKLM \
# "Software\Microsoft\Windows\CurrentVersion\Uninstall\Tartube" \
# "DisplayVersion" "2.3.518"
# "DisplayVersion" "2.3.549"
# Create uninstaller
WriteUninstaller "$INSTDIR\Uninstall.exe"

View File

@ -42,8 +42,8 @@ import mainapp
# 'Global' variables
__packagename__ = 'tartube'
__version__ = '2.3.518'
__date__ = '5 Apr 2022'
__version__ = '2.3.549'
__date__ = '9 Apr 2022'
__copyright__ = 'Copyright \xa9 2019-2022 A S Lewis'
__license__ = """
Copyright \xa9 2019-2022 A S Lewis.

View File

@ -42,8 +42,8 @@ import mainapp
# 'Global' variables
__packagename__ = 'tartube'
__version__ = '2.3.518'
__date__ = '5 Apr 2022'
__version__ = '2.3.549'
__date__ = '9 Apr 2022'
__copyright__ = 'Copyright \xa9 2019-2022 A S Lewis'
__license__ = """
Copyright \xa9 2019-2022 A S Lewis.

View File

@ -42,8 +42,8 @@ import mainapp
# 'Global' variables
__packagename__ = 'tartube'
__version__ = '2.3.518'
__date__ = '5 Apr 2022'
__version__ = '2.3.549'
__date__ = '9 Apr 2022'
__copyright__ = 'Copyright \xa9 2019-2022 A S Lewis'
__license__ = """
Copyright \xa9 2019-2022 A S Lewis.

View File

@ -1,4 +1,4 @@
.TH man 1 "5 Apr 2022" "2.3.518" "tartube man page"
.TH man 1 "9 Apr 2022" "2.3.549" "tartube man page"
.SH NAME
tartube \- GUI front-end for youtube-dl
.SH SYNOPSIS

View File

@ -1,6 +1,6 @@
[Desktop Entry]
Name=Tartube
Version=2.3.518
Version=2.3.549
Exec=tartube
Icon=tartube
Type=Application

View File

@ -185,7 +185,7 @@ for path in glob.glob('sounds/*'):
# Setup
setuptools.setup(
name='tartube',
version='2.3.518',
version='2.3.549',
description='GUI front-end for youtube-dl',
long_description=long_description,
long_description_content_type='text/plain',

View File

@ -4646,16 +4646,23 @@ class OptionsEditWin(GenericEditWin):
else:
entry4.set_text(_('These options are not applied to anything'))
self.add_label(grid,
_(
'Additional download options, e.g. --write-subs (do not use -o' \
+ ' or --output)',
),
0, 3, grid_width, 1,
)
if self.app_obj.simple_options_flag:
self.add_label(grid,
_(
'Additional download options, e.g. --write-subs (do not use' \
+ ' -o or --output)',
),
0, 3, grid_width, 1,
)
if not self.app_obj.simple_options_flag:
self.add_label(grid,
_('Additional download options'),
0, 3, 1, 1,
)
if os.name == 'nt':
checkbutton = self.add_checkbutton(grid,
@ -4663,7 +4670,7 @@ class OptionsEditWin(GenericEditWin):
'Use ONLY these options (Tartube adds the output folder)',
),
None,
0, 4, grid_width, 1,
1, 3, (grid_width - 1), 1,
)
# (Signal connect appears below)
@ -4675,16 +4682,16 @@ class OptionsEditWin(GenericEditWin):
+ ' directory)',
),
None,
0, 4, grid_width, 1,
1, 3, (grid_width - 1), 1,
)
# (Signal connect appears below)
checkbutton.set_active(self.retrieve_val('direct_cmd_flag'))
checkbutton2 = self.add_checkbutton(grid,
_('Use only the URL specified below'),
_('If URLs are specified below, use only those URLs'),
'direct_url_flag',
0, 5, grid_width, 1,
1, 4, (grid_width - 1), 1,
)
# (Signal connects from above)
@ -4696,25 +4703,25 @@ class OptionsEditWin(GenericEditWin):
self.add_textview(grid,
'extra_cmd_string',
0, 6, grid_width, 1,
0, 5, grid_width, 1,
)
if self.app_obj.simple_options_flag:
frame = self.add_pixbuf(grid,
'hand_right_large',
0, 7, 1, 1,
0, 6, 1, 1,
)
frame.set_hexpand(False)
else:
frame = self.add_pixbuf(grid,
'hand_left_large',
0, 7, 1, 1,
0, 6, 1, 1,
)
frame.set_hexpand(False)
button2 = Gtk.Button()
grid.attach(button2, 1, 7, (grid_width - 1), 1)
grid.attach(button2, 1, 6, (grid_width - 1), 1)
if not self.app_obj.simple_options_flag:
button2.set_label(_('Hide advanced download options'))
else:
@ -4723,14 +4730,14 @@ class OptionsEditWin(GenericEditWin):
frame2 = self.add_pixbuf(grid,
'copy_large',
0, 8, 1, 1,
0, 7, 1, 1,
)
frame2.set_hexpand(False)
button3 = Gtk.Button(
_('Import general download options into this window'),
)
grid.attach(button3, 1, 8, (grid_width - 1), 1)
grid.attach(button3, 1, 7, (grid_width - 1), 1)
button3.connect('clicked', self.on_clone_options_clicked)
if self.edit_obj == self.app_obj.general_options_obj:
# No point cloning the General Options Manager onto itself
@ -4738,14 +4745,14 @@ class OptionsEditWin(GenericEditWin):
frame3 = self.add_pixbuf(grid,
'warning_large',
0, 9, 1, 1,
0, 8, 1, 1,
)
frame3.set_hexpand(False)
button4 = Gtk.Button(
_('Completely reset all download options to their default values'),
)
grid.attach(button4, 1, 9, (grid_width - 1), 1)
grid.attach(button4, 1, 8, (grid_width - 1), 1)
button4.connect('clicked', self.on_reset_options_clicked)
@ -4780,7 +4787,7 @@ class OptionsEditWin(GenericEditWin):
self.setup_downloads_videos_tab(inner_notebook)
self.setup_downloads_playlists_tab(inner_notebook)
self.setup_downloads_limits_tab(inner_notebook)
self.setup_downloads_formats_tab(inner_notebook)
self.setup_downloads_merge_tab(inner_notebook)
self.setup_downloads_extractor_tab(inner_notebook)
self.setup_downloads_filtering_tab(inner_notebook)
self.setup_downloads_external_tab(inner_notebook)
@ -4970,11 +4977,11 @@ class OptionsEditWin(GenericEditWin):
row_count = self.downloads_views_widgets(grid, row_count)
def setup_downloads_formats_tab(self, inner_notebook):
def setup_downloads_merge_tab(self, inner_notebook):
"""Called by self.setup_downloads_tab().
Sets up the 'Formats' inner notebook tab.
Sets up the 'Merge' inner notebook tab.
Args:
@ -4983,13 +4990,14 @@ class OptionsEditWin(GenericEditWin):
"""
tab, grid = self.add_inner_notebook_tab(
_('_Formats'),
_('_Merge'),
inner_notebook,
)
# Video format options (yt-dlp only)
# Video/audio merge options (yt-dlp only)
self.add_label(grid,
'<u>' + _('Video format options') + '</u>' + self.ytdlp_only(),
'<u>' + _('Video/audio merge options') + '</u>' \
+ self.ytdlp_only(),
0, 0, 1, 1,
)
@ -5007,26 +5015,6 @@ class OptionsEditWin(GenericEditWin):
)
self.add_tooltip('--audio-multistreams', checkbutton2)
checkbutton3 = self.add_checkbutton(grid,
_(
'Check formats selected are actually downloadable' \
+ ' (Experimental)',
),
'check_formats',
0, 3, 1, 1,
)
self.add_tooltip('--check-formats', checkbutton3)
checkbutton4 = self.add_checkbutton(grid,
_(
'Allow unplayable formats to be listed and downloaded (also' \
+ ' disables post-processing)',
),
'allow_unplayable_formats',
0, 4, 1, 1,
)
self.add_tooltip('--allow-unplayable-formats', checkbutton4)
def setup_downloads_extractor_tab(self, inner_notebook):
@ -5118,7 +5106,7 @@ class OptionsEditWin(GenericEditWin):
"""
tab, grid = self.add_inner_notebook_tab(
_('F_iltering'),
_('_Filtering'),
inner_notebook,
)
@ -6752,6 +6740,33 @@ class OptionsEditWin(GenericEditWin):
)
self.add_tooltip('--youtube-skip-dash-manifest', checkbutton2)
# Other format options (yt-dlp only)
self.add_label(grid,
'<u>' + _('Other format options') + '</u>' \
+ self.ytdlp_only(),
0, (8 + extra_row), grid_width, 1,
)
checkbutton3 = self.add_checkbutton(grid,
_(
'Check formats selected are actually downloadable' \
+ ' (Experimental)',
),
'check_formats',
0, (9 + extra_row), grid_width, 1,
)
self.add_tooltip('--check-formats', checkbutton3)
checkbutton4 = self.add_checkbutton(grid,
_(
'Allow unplayable formats to be listed and downloaded (also' \
+ ' disables post-processing)',
),
'allow_unplayable_formats',
0, (10 + extra_row), grid_width, 1,
)
self.add_tooltip('--allow-unplayable-formats', checkbutton4)
def setup_convert_tab(self):
@ -14421,10 +14436,18 @@ class VideoEditWin(GenericEditWin):
self.on_clear_stamp_button_clicked,
)
button5 = Gtk.Button(_('Reset list using video description'))
grid2.attach(button5, 2, 1, 2, 1)
button5 = Gtk.Button(_('Reset list using copied text'))
grid2.attach(button5, 0, 1, 2, 1)
button5.set_hexpand(True)
button5.connect(
'clicked',
self.on_copy_stamp_button_clicked,
)
button6 = Gtk.Button(_('Reset list using video description'))
grid2.attach(button6, 2, 1, 2, 1)
button6.set_hexpand(True)
button6.connect(
'clicked',
self.on_extract_stamp_button_clicked,
)
@ -14452,7 +14475,7 @@ class VideoEditWin(GenericEditWin):
if mini_list[2] is None:
clip_title = ''
else:
clip_title = mini_list[1]
clip_title = mini_list[2]
self.timestamp_liststore.append(
[ start_stamp, stop_stamp, clip_title ],
@ -15616,6 +15639,50 @@ class VideoEditWin(GenericEditWin):
SystemPrefWin(self.app_obj, 'clips')
def on_copy_stamp_button_clicked(self, button):
"""Called from a callback in self.setup_timestamps_tab().
Updates the video's timestamp list using text the user has copied and
pasted into a dialogue window.
Args:
button (Gtk.Button): The widget clicked
"""
# Open the dialogue window
dialogue_win = mainwin.AddStampDialogue(
self,
self.app_obj.main_win_obj,
)
response = dialogue_win.run()
# Retrieve user choices from the dialogue window
if response == Gtk.ResponseType.OK:
text = dialogue_win.textbuffer.get_text(
dialogue_win.textbuffer.get_start_iter(),
dialogue_win.textbuffer.get_end_iter(),
# Don't include hidden characters
False,
)
# (Do not modify the existing list of timestampes, if no text was
# added to the dialogue window)
if text != '':
self.edit_obj.extract_timestamps_from_descrip(
self.app_obj,
text,
)
self.setup_timestamps_tab_update_treeview()
# ...before destroying the dialogue window
dialogue_win.destroy()
def on_delete_slice_button_clicked(self, button, treeview):
"""Called from a callback in self.setup_slices_tab().
@ -20578,70 +20645,60 @@ class SystemPrefWin(GenericPrefWin):
checkbutton8.connect('toggled', self.on_disable_dl_all_toggled)
checkbutton9 = self.add_checkbutton(grid,
_(
'Show a \'Custom download all\' button in the Videos tab',
),
self.app_obj.show_custom_dl_button_flag,
True, # Can be toggled by user
0, 7, grid_width, 1,
)
checkbutton9.connect('toggled', self.on_show_custom_dl_button_toggled)
checkbutton10 = self.add_checkbutton(grid,
_(
'In the Progress tab, hide finished downloads',
),
self.app_obj.progress_list_hide_flag,
True, # Can be toggled by user
0, 8, 1, 1,
0, 7, 1, 1,
)
checkbutton10.connect('toggled', self.on_hide_button_toggled)
checkbutton9.connect('toggled', self.on_hide_button_toggled)
checkbutton11 = self.add_checkbutton(grid,
checkbutton10 = self.add_checkbutton(grid,
_('Show downloads in reverse order'),
self.app_obj.results_list_reverse_flag,
True, # Can be toggled by user
1, 8, 2, 1,
1, 7, 2, 1,
)
checkbutton11.connect('toggled', self.on_reverse_button_toggled)
checkbutton10.connect('toggled', self.on_reverse_button_toggled)
checkbutton12 = self.add_checkbutton(grid,
checkbutton11 = self.add_checkbutton(grid,
_('When Tartube starts, automatically open the Classic Mode tab'),
self.app_obj.show_classic_tab_on_startup_flag,
True, # Can be toggled by user
0, 9, grid_width, 1,
0, 8, grid_width, 1,
)
checkbutton12.connect(
checkbutton11.connect(
'toggled',
self.on_show_classic_mode_button_toggled,
)
if __main__.__pkg_no_download_flag__:
checkbutton12.set_sensitive(False)
checkbutton11.set_sensitive(False)
checkbutton13 = self.add_checkbutton(grid,
checkbutton12 = self.add_checkbutton(grid,
_(
'In the Classic Mode tab, when adding URLs, remove duplicates' \
+ ' rather than retaining them',
),
self.app_obj.classic_duplicate_remove_flag,
True, # Can be toggled by user
0, 10, grid_width, 1,
0, 9, grid_width, 1,
)
checkbutton13.connect(
checkbutton12.connect(
'toggled',
self.on_remove_duplicate_button_toggled,
)
checkbutton14 = self.add_checkbutton(grid,
checkbutton13 = self.add_checkbutton(grid,
_(
'In the Errors/Warnings tab, don\'t reset the tab text when' \
+ ' it is clicked',
),
self.app_obj.system_msg_keep_totals_flag,
True, # Can be toggled by user
0, 11, grid_width, 1,
0, 10, grid_width, 1,
)
checkbutton14.connect('toggled', self.on_system_keep_button_toggled)
checkbutton13.connect('toggled', self.on_system_keep_button_toggled)
def setup_windows_videos_tab(self, inner_notebook):
@ -20666,113 +20723,123 @@ class SystemPrefWin(GenericPrefWin):
)
checkbutton = self.add_checkbutton(grid,
_(
'Show smaller icons in the Video Index',
),
self.app_obj.show_small_icons_in_index_flag,
_('Show a \'Custom download all\' button'),
self.app_obj.show_custom_dl_button_flag,
True, # Can be toggled by user
0, 1, grid_width, 1,
)
checkbutton.connect('toggled', self.on_show_custom_dl_button_toggled)
checkbutton2 = self.add_checkbutton(grid,
_('Allow each row to be marked for checking/downloading'),
self.app_obj.show_marker_in_index_flag,
True, # Can be toggled by user
0, 2, grid_width, 1,
)
checkbutton.connect('toggled', self.on_show_small_icons_toggled)
checkbutton2.connect('toggled', self.on_show_selector_button_toggled)
checkbutton2 = self.add_checkbutton(grid,
checkbutton3 = self.add_checkbutton(grid,
_('Show smaller icons'),
self.app_obj.show_small_icons_in_index_flag,
True, # Can be toggled by user
0, 3, grid_width, 1,
)
checkbutton3.connect('toggled', self.on_show_small_icons_toggled)
checkbutton4 = self.add_checkbutton(grid,
_(
'In the Video Index, show detailed statistics about the videos' \
+ ' in each channel / playlist / folder',
'Show detailed statistics about the videos in each channel' \
+ ' / playlist / folder',
),
self.app_obj.complex_index_flag,
True, # Can be toggled by user
0, 3, grid_width, 1,
0, 4, grid_width, 1,
)
checkbutton2.connect('toggled', self.on_complex_button_toggled)
checkbutton4.connect('toggled', self.on_complex_button_toggled)
checkbutton3 = self.add_checkbutton(grid,
checkbutton5 = self.add_checkbutton(grid,
_(
'After clicking on a folder, automatically expand/collapse the' \
+ ' tree around it',
),
self.app_obj.auto_expand_video_index_flag,
True, # Can be toggled by user
0, 4, grid_width, 1,
0, 5, grid_width, 1,
)
# (Signal connect appears below)
checkbutton4 = self.add_checkbutton(grid,
checkbutton6 = self.add_checkbutton(grid,
_(
'Expand the whole tree, not just the level beneath the clicked' \
+ ' folder',
),
self.app_obj.full_expand_video_index_flag,
True, # Can be toggled by user
0, 5, grid_width, 1,
0, 6, grid_width, 1,
)
if not self.app_obj.auto_expand_video_index_flag:
checkbutton4.set_sensitive(False)
checkbutton6.set_sensitive(False)
# (Signal connect appears below)
# (Signal connects from above)
checkbutton3.connect(
checkbutton5.connect(
'toggled',
self.on_expand_tree_toggled,
checkbutton4,
checkbutton6,
)
checkbutton4.connect('toggled', self.on_expand_full_tree_toggled)
checkbutton6.connect('toggled', self.on_expand_full_tree_toggled)
# Video Catalogue (right side of the Videos tab)
self.add_label(grid,
'<u>' + _('Video Catalogue (right side of the Videos tab)') \
+ '</u>',
0, 6, grid_width, 1,
)
checkbutton5 = self.add_checkbutton(grid,
_(
'Show \'today\' and \'yesterday\' as the date, when possible',
),
self.app_obj.show_pretty_dates_flag,
True, # Can be toggled by user
0, 7, grid_width, 1,
)
checkbutton5.connect('toggled', self.on_pretty_date_button_toggled)
checkbutton6 = self.add_checkbutton(grid,
_('Show livestreams with a different background colour'),
self.app_obj.livestream_use_colour_flag,
checkbutton7 = self.add_checkbutton(grid,
_('Show \'today\' and \'yesterday\' as the date, when possible'),
self.app_obj.show_pretty_dates_flag,
True, # Can be toggled by user
0, 8, grid_width, 1,
)
# (Signal connect appears below)
checkbutton7.connect('toggled', self.on_pretty_date_button_toggled)
checkbutton7 = self.add_checkbutton(grid,
_('Use same background colours for livestream and debut videos'),
self.app_obj.livestream_simple_colour_flag,
checkbutton8 = self.add_checkbutton(grid,
_('Show livestreams with a different background colour'),
self.app_obj.livestream_use_colour_flag,
True, # Can be toggled by user
0, 9, grid_width, 1,
)
# (Signal connect appears below)
checkbutton9 = self.add_checkbutton(grid,
_('Use same background colours for livestream and debut videos'),
self.app_obj.livestream_simple_colour_flag,
True, # Can be toggled by user
0, 10, grid_width, 1,
)
if not self.app_obj.livestream_use_colour_flag:
checkbutton7.set_sensitive(False)
checkbutton9.set_sensitive(False)
# (Signal connect appears below)
# (Signal connects from above)
checkbutton6.connect(
checkbutton8.connect(
'toggled',
self.on_livestream_colour_button_toggled,
checkbutton7,
checkbutton9,
)
checkbutton7.connect(
checkbutton9.connect(
'toggled',
self.on_livestream_simple_button_toggled,
)
checkbutton8 = self.add_checkbutton(grid,
_(
'Channel and playlist names are clickable (grid mode only)',
),
checkbutton10 = self.add_checkbutton(grid,
_('Channel and playlist names are clickable (grid mode only)'),
self.app_obj.catalogue_clickable_container_flag,
True, # Can be toggled by user
0, 10, grid_width, 1,
0, 11, grid_width, 1,
)
checkbutton8.connect('toggled', self.on_clickable_button_toggled)
checkbutton10.connect('toggled', self.on_clickable_button_toggled)
def setup_windows_drag_tab(self, inner_notebook):
@ -21072,6 +21139,24 @@ class SystemPrefWin(GenericPrefWin):
_('Selected broadcasting videos'),
)
self.setup_windows_colours_tab_add_row(grid,
8,
'drag_drop_notify',
_('Drag and Drop notification'),
)
self.setup_windows_colours_tab_add_row(grid,
9,
'drag_drop_odd',
_('Drag and Drop background 1'),
)
self.setup_windows_colours_tab_add_row(grid,
10,
'drag_drop_even',
_('Drag and Drop background 2'),
)
def setup_windows_colours_tab_add_row(self, grid, row_num, key, descrip):
@ -22729,8 +22814,8 @@ class SystemPrefWin(GenericPrefWin):
checkbutton2 = self.add_checkbutton(grid,
_(
'Create an archive file when downloading from the Classic' \
+ ' Mode tab (not recommended)',
'Create an archive file when downloading from the Classic Mode' \
+ ' tab',
),
self.app_obj.classic_ytdl_archive_flag,
True, # Can be toggled by user
@ -22738,6 +22823,14 @@ class SystemPrefWin(GenericPrefWin):
)
checkbutton2.connect('toggled', self.on_archive_classic_button_toggled)
self.add_label(grid,
'<i>' + _(
'This setting should only be enabled when downloading' \
+ ' channels and playlists',
) + '</i>',
0, 9, grid_width, 1,
)
def setup_operations_livestreams_tab(self, inner_notebook):
@ -24337,11 +24430,11 @@ class SystemPrefWin(GenericPrefWin):
for i, column_title in enumerate(
[
'#', _('Name'), _('Videos tab'), _('Classic Mode tab'),
_('Applied to media'),
'#', _('Name'), _('Videos tab'), _('Classic Mode'),
_('Dropzone'), _('Applied to media'),
]
):
if i == 2 or i == 3:
if i >= 2 and i <= 4:
renderer_toggle = Gtk.CellRendererToggle()
column_toggle = Gtk.TreeViewColumn(
column_title,
@ -24360,7 +24453,7 @@ class SystemPrefWin(GenericPrefWin):
treeview.append_column(column_text)
column_text.set_resizable(True)
self.options_liststore = Gtk.ListStore(int, str, bool, bool, str)
self.options_liststore = Gtk.ListStore(int, str, bool, bool, bool, str)
treeview.set_model(self.options_liststore)
# Initialise the list
@ -24511,6 +24604,11 @@ class SystemPrefWin(GenericPrefWin):
else:
row_list.append(False)
if options_obj.uid in self.app_obj.classic_dropzone_list:
row_list.append(True)
else:
row_list.append(False)
if options_obj.dbid is None:
row_list.append('')
@ -25290,7 +25388,9 @@ class SystemPrefWin(GenericPrefWin):
"""Called from callback in self.setup_operations_archive_tab().
Enables/disables creation of youtube-dl's archive file,
ytdl-archive.txt, when downloading from the Classic Mode tab.
ytdl-archive.txt, when downloading from the Classic Mode tab. Toggling
the corresponding Gtk.ToggleButton in the Classic Mode tab sets the IV
(and makes sure the two buttons have the same status).
Args:
@ -25298,12 +25398,13 @@ class SystemPrefWin(GenericPrefWin):
"""
if checkbutton.get_active() \
and not self.app_obj.classic_ytdl_archive_flag:
self.app_obj.set_classic_ytdl_archive_flag(True)
elif not checkbutton.get_active() \
and self.app_obj.classic_ytdl_archive_flag:
self.app_obj.set_classic_ytdl_archive_flag(False)
main_win_obj = self.app_obj.main_win_obj
other_flag = main_win_obj.classic_archive_button.get_active()
if (checkbutton.get_active() and not other_flag):
main_win_obj.classic_archive_button.set_active(True)
elif (not checkbutton.get_active() and other_flag):
main_win_obj.classic_archive_button.set_active(False)
def on_archive_radiobutton_toggled(self, widget, radiobutton, \
@ -30666,6 +30767,27 @@ class SystemPrefWin(GenericPrefWin):
)
def on_show_selector_button_toggled(self, checkbutton):
"""Called from callback in self.setup_windows_main_window_tab().
Enables/disables showing the selector button in each row of the Video
Index.
Args:
checkbutton (Gtk.CheckButton): The widget clicked
"""
if checkbutton.get_active() \
and not self.app_obj.show_marker_in_index_flag:
self.app_obj.set_show_marker_in_index_flag(True)
elif not checkbutton.get_active() \
and self.app_obj.show_marker_in_index_flag:
self.app_obj.set_show_marker_in_index_flag(False)
def on_show_small_icons_toggled(self, checkbutton):
"""Called from callback in self.setup_windows_videos_tab().

View File

@ -1381,6 +1381,14 @@ class DownloadWorker(threading.Thread):
for vid in self.downloader_obj.video_msg_buffer_dict.keys():
self.downloader_obj.process_error_warning(vid)
# Unless the download was stopped manually (return code 5), any
# 'dummy' media.Video objects can be set, so that their URLs are
# not remembered in the next Tartube session
if isinstance(media_data_obj, media.Video) \
and media_data_obj.dummy_flag \
and return_code < 5:
media_data_obj.set_dummy_dl_flag(True)
# If the download stalled, -1 is returned. If we're allowed to
# restart a stalled download, do that; otherwise give up
if return_code > -1 \
@ -1498,7 +1506,7 @@ class DownloadWorker(threading.Thread):
and custom_dl_obj.split_flag \
and media_data_obj.stamp_list
) or app_obj.temp_stamp_list:
return_code = self.downloader_obj.do_download_clip()
return_code = self.downloader_obj.do_download_clips()
else:
return_code = self.downloader_obj.do_download_remove_slices()
@ -1645,6 +1653,7 @@ class DownloadWorker(threading.Thread):
# the whole RSS feed)
time_limit_video_obj = None
check_source_list = []
check_name_list = []
if app_obj.livestream_max_days:
@ -1656,15 +1665,16 @@ class DownloadWorker(threading.Thread):
)
for child_obj in container_obj.child_list:
if child_obj.source:
# An entry in the RSS feed is a new livestream, if it
# doesn't match one of the videos in this list
# (We don't need to check each RSS entry against the
# entire contents of the channel/playlist - which might
# be thousands of videos - just those up to the time
# limit)
# An entry in the RSS feed is a new livestream, if it doesn't
# match one of the videos in these lists
# (We don't need to check each RSS entry against the entire
# contents of the channel/playlist - which might be thousands
# of videos - just those up to the time limit)
if child_obj.source:
check_source_list.append(child_obj.source)
if child_obj.name != app_obj.default_video_name:
check_name_list.append(child_obj.name)
# The time limit will apply to this video, when found
for child_obj in container_obj.child_list:
@ -1682,6 +1692,8 @@ class DownloadWorker(threading.Thread):
for child_obj in container_obj.child_list:
if child_obj.source:
check_source_list.append(child_obj.source)
if child_obj.name != app_obj.default_video_name:
check_name_list.append(child_obj.name)
for child_obj in container_obj.child_list:
if child_obj.source \
@ -1707,7 +1719,8 @@ class DownloadWorker(threading.Thread):
# for livestreams now
break
elif not entry_dict['link'] in check_source_list:
elif not entry_dict['link'] in check_source_list \
and not entry_dict['title'] in check_name_list:
# New livestream detected. Create a new JSONFetcher object to
# fetch its JSON data
@ -3889,6 +3902,10 @@ class VideoDownloader(object):
else:
# Register the download with DownloadManager, so that
# download limits can be applied, if required
self.download_manager_obj.register_video('old')
# This video applies towards the limit (if any) specified
# by mainapp.TartubeApp.operation_download_limit
self.video_limit_count += 1
@ -3947,6 +3964,10 @@ class VideoDownloader(object):
),
)
# Register the download with DownloadManager, so that
# download limits can be applied, if required
self.download_manager_obj.register_video('new')
# The probable video ID, if captured, can now be reset
self.probable_video_id = None
@ -4906,6 +4927,14 @@ class VideoDownloader(object):
# (Flag set to True when self.confirm_new_video(), etc, are called)
confirm_flag = False
# The '[download] XXX has already been recorded in the archive'
# message does not cause a call to self.confirm_new_video(), etc,
# so we must handle it here
# (Note that the first word might be '[download]', or '[Youtube]', etc)
if re.search('^.*has already been recorded in the archive$', stdout):
self.download_manager_obj.register_video('other')
return dl_stat_dict
# Extract the data
stdout_list[0] = stdout_list[0].lstrip('\r')
if stdout_list[0] == '[download]':

View File

@ -735,7 +735,7 @@ class FFmpegOptionsManager(object):
source file is used (so that a specimen system command can be
displayed in the edit window)
start_point, stop_points, clip_title, clip_dir (str):
start_point, stop_point, clip_title, clip_dir (str):
When splitting a video, the points at which to start/stop
(timestamps or values in seconds), the clip title, and the
destination directory for sections (if not the same as the
@ -1142,11 +1142,8 @@ class FFmpegOptionsManager(object):
return_list.append(str(start_point))
# (If no timestamp is specified, the end of the video is used)
return_list.append('-to')
if stop_point is None:
# (A specimen time, in seconds)
return_list.append('100')
else:
if stop_point is not None:
return_list.append('-to')
return_list.append(str(stop_point))
if clip_title is None or clip_title == "":
@ -1179,3 +1176,11 @@ class FFmpegOptionsManager(object):
return source_thumb_path, output_path, return_list
else:
return source_video_path, output_path, return_list

View File

@ -391,6 +391,9 @@ class TartubeApp(Gtk.Application):
# should be replaced by a custom set of icons (in case the stock
# icons are not visible, for some reason)
self.show_custom_icons_flag = False
# Flag set to True if a marker should be visible on each row in the
# Video Index, false if not
self.show_marker_in_index_flag = True
# Flag set to True if small icons should be used in the Video Index,
# False if large icons should be used
self.show_small_icons_in_index_flag = False
@ -1769,9 +1772,6 @@ class TartubeApp(Gtk.Application):
# downloading from the Classic Mode tab (this is marked 'not
# recommended' in the edit window)
self.classic_ytdl_archive_flag = False
# Flag set to True when re-downloading video(s), so that the archive
# file is not used at all (otherwise, the re-download will fail)
self.block_ytdl_archive_flag = False
# Flag set to True if, when checking videos/channels/playlists, we
# should timeout after 60 seconds (in case youtube-dl gets stuck
@ -2145,14 +2145,18 @@ class TartubeApp(Gtk.Application):
# Dictionary of default background colours
self.default_bg_table = {
# Not selected
'live_wait': [1, 0, 0, 0.1], # Red
'live_now': [0, 1, 0, 0.2], # Green
'debut_wait': [1, 1, 0, 0.2], # Yellow
'debut_now': [0, 1, 1, 0.2], # Cyan
'live_wait': [1, 0, 0, 0.1], # Red
'live_now': [0, 1, 0, 0.2], # Green
'debut_wait': [1, 1, 0, 0.2], # Yellow
'debut_now': [0, 1, 1, 0.2], # Cyan
# Selected
'select': [0, 0, 1, 0.1], # Blue
'select_wait': [1, 0, 1, 0.1], # Purple
'select_live': [1, 0, 1, 0.1], # Purple
'select': [0, 0, 1, 0.1], # Blue
'select_wait': [1, 0, 1, 0.1], # Purple
'select_live': [1, 0, 1, 0.1], # Purple
# Drag and drop tab
'drag_drop_notify': [1, 0, 1, 0.1], # Purple
'drag_drop_odd': [1, 1, 0, 0.1], # Orange
'drag_drop_even': [1, 1, 0, 0.05], # Pale orange
}
# Dictionary of customisable colours
self.custom_bg_table = self.default_bg_table.copy()
@ -2413,6 +2417,13 @@ class TartubeApp(Gtk.Application):
show_hidden_menu_action.connect('activate', self.on_menu_show_hidden)
self.add_action(show_hidden_menu_action)
unmark_all_menu_action = Gio.SimpleAction.new(
'unmark_all_menu',
None,
)
unmark_all_menu_action.connect('activate', self.on_menu_unmark_all)
self.add_action(unmark_all_menu_action)
if self.debug_test_media_menu_flag:
test_menu_action = Gio.SimpleAction.new('test_menu', None)
test_menu_action.connect('activate', self.on_menu_test)
@ -2791,7 +2802,7 @@ class TartubeApp(Gtk.Application):
'check_all_button',
None,
)
check_all_button_action.connect('activate', self.on_menu_check_all)
check_all_button_action.connect('activate', self.on_button_check_all)
self.add_action(check_all_button_action)
download_all_button_action = Gio.SimpleAction.new(
@ -2800,7 +2811,7 @@ class TartubeApp(Gtk.Application):
)
download_all_button_action.connect(
'activate',
self.on_menu_download_all,
self.on_button_download_all,
)
self.add_action(download_all_button_action)
@ -2810,7 +2821,7 @@ class TartubeApp(Gtk.Application):
)
custom_dl_all_button_action.connect(
'activate',
self.on_menu_custom_dl_all,
self.on_button_custom_dl_all,
)
self.add_action(custom_dl_all_button_action)
@ -2909,6 +2920,16 @@ class TartubeApp(Gtk.Application):
)
self.add_action(classic_stop_button_action)
classic_archive_button_action = Gio.SimpleAction.new(
'classic_archive_button',
None,
)
classic_archive_button_action.connect(
'activate',
self.on_button_classic_archive,
)
self.add_action(classic_archive_button_action)
classic_ffmpeg_button_action = Gio.SimpleAction.new(
'classic_ffmpeg_button',
None,
@ -3974,6 +3995,9 @@ class TartubeApp(Gtk.Application):
if version >= 2001036: # v2.1.036
self.show_custom_icons_flag \
= json_dict['show_custom_icons_flag']
if version >= 2003541: # v2.3.541
self.show_marker_in_index_flag \
= json_dict['show_marker_in_index_flag']
if version >= 2001036: # v2.1.036
self.show_small_icons_in_index_flag \
= json_dict['show_small_icons_in_index_flag']
@ -4591,6 +4615,12 @@ class TartubeApp(Gtk.Application):
if version >= 2003195: # v2.3.195
self.custom_bg_table = json_dict['custom_bg_table']
if version < 2003537: # v2.3.537
# (New key-value pairs added)
for key in [
'drag_drop_notify', 'drag_drop_odd', 'drag_drop_even',
]:
self.custom_bg_table[key] = self.default_bg_table[key]
if version >= 2003230: # v2.3.230
self.ytdlp_filter_options_flag \
@ -5080,6 +5110,7 @@ class TartubeApp(Gtk.Application):
'show_tooltips_flag': self.show_tooltips_flag,
'show_tooltips_extra_flag': self.show_tooltips_extra_flag,
'show_custom_icons_flag': self.show_custom_icons_flag,
'show_marker_in_index_flag': self.show_marker_in_index_flag,
'show_small_icons_in_index_flag': \
self.show_small_icons_in_index_flag,
'auto_expand_video_index_flag': self.auto_expand_video_index_flag,
@ -5561,6 +5592,7 @@ class TartubeApp(Gtk.Application):
# (Don't reset the Errors/Warnings tab, as failed attempts to load a
# database generate messages there)
if self.main_win_obj:
self.main_win_obj.video_index_reset_marker()
self.main_win_obj.video_index_reset()
self.main_win_obj.video_catalogue_reset()
self.main_win_obj.progress_list_reset()
@ -7078,6 +7110,13 @@ class TartubeApp(Gtk.Application):
custom_dl_obj.dl_if_stream_flag = False
custom_dl_obj.dl_if_old_stream_flag = False
if version < 2003536: # v2.3.536
# This version adds a new IV to media.Video objects
for media_data_obj in self.media_reg_dict.values():
if isinstance(media_data_obj, media.Video):
media_data_obj.dummy_dl_flag = False
# --- Do this last, or the call to .check_integrity_db() fails -------
# --------------------------------------------------------------------
@ -10363,10 +10402,6 @@ class TartubeApp(Gtk.Application):
if self.main_win_obj.is_visible():
self.main_win_obj.show_all()
# If the youtube-dl archive file(s) were temporarily blocked for a
# video re-download, re-enable them
self.block_ytdl_archive_flag = True
# If Tartube is due to shut down, then shut it down
show_newbie_dialogue_flag = False
@ -11376,7 +11411,7 @@ class TartubeApp(Gtk.Application):
del_corrupt_flag: True if corrupted video files should be
deleted
exist_Flag: True if video files that should exist should be
exist_flag: True if video files that should exist should be
checked, in case they don't (and vice-versa)
del_video_flag: True if downloaded video files should be
@ -11386,6 +11421,16 @@ class TartubeApp(Gtk.Application):
name should be deleted (as artefacts of post-processing
with FFmpeg or AVConv)
remove_no_url_flag: True if any media.Video objects whose URL
is not set should be removed from the database (no files
are deleted)
remove_dupe_flag: True if any media.Video objects, which are
not marked as downloaded and which share a URL with
another media.Video object with the same parent and which
is marked as downloaded, should be removed from the
database (no files are deleted)
del_archive_flag: True if all youtube-dl archive files should
be deleted
@ -12023,6 +12068,11 @@ class TartubeApp(Gtk.Application):
no_sort_flag,
)
# Update the video name/nickname, if it is not set
if video_obj.name == self.default_video_name:
video_obj.set_name(filename)
video_obj.set_nickname(filename)
# Update the filepath. Even if it is already known, the extension may
# have changed (for example, after checking a video, then downloading
# it)
@ -12218,7 +12268,7 @@ class TartubeApp(Gtk.Application):
if video_obj.name == self.default_video_name:
video_obj.set_name(video_obj.file_name)
# (The video's title, stored in the .nickname IV, will be updated
# from the JSON data in a momemnt)
# from the JSON data in a moment)
video_obj.set_nickname(video_obj.file_name)
# Set the file size
@ -14124,7 +14174,7 @@ class TartubeApp(Gtk.Application):
if response != Gtk.ResponseType.OK:
return
# Get a second confirmation, if required to delete files
# Get a second confirmation
if delete_file_flag:
self.dialogue_manager_obj.show_msg_dialogue(
@ -14138,44 +14188,60 @@ class TartubeApp(Gtk.Application):
# Arguments passed directly to .delete_container_continue()
{
'yes': 'delete_container_continue',
'data': [media_data_obj, empty_flag],
'data': [media_data_obj, empty_flag, delete_file_flag],
}
)
# No second confirmation required, so we can proceed directly to the
# call to self.delete_container_complete()
else:
self.delete_container_complete(media_data_obj, empty_flag)
self.dialogue_manager_obj.show_msg_dialogue(
_(
'Are you SURE you want to remove these items from your' \
+ ' database? This procedure cannot be reversed!',
),
'question',
'yes-no',
None, # Parent window is main window
# Arguments passed directly to .delete_container_continue()
{
'yes': 'delete_container_continue',
'data': [media_data_obj, empty_flag, delete_file_flag],
}
)
def delete_container_continue(self, data_list):
"""Called by self.delete_container().
When deleting a container, after the user has specified that files
should be deleted too, this function is called to delete those files.
After getting a confirmation from the user, continue with the
deletion process.
Args:
data_list (list): A list of two items. The first is the container
data_list (list): A list of three items. The first is the container
media data object; the second is a flag set to True if the
container should be emptied, rather than being deleted
container should be emptied, rather than being deleted; the
third is True if files should be deleted from the user's
filesystem
"""
# Unpack the arguments
media_data_obj = data_list[0]
empty_flag = data_list[1]
delete_file_flag = data_list[2]
# Confirmation obtained, so delete the files
container_dir = media_data_obj.get_default_dir(self)
if os.path.isdir(container_dir):
self.remove_directory(container_dir)
if delete_file_flag:
# If emptying the container rather than deleting it, just create a
# replacement (empty) directory on the filesystem
if empty_flag:
self.make_directory(container_dir)
container_dir = media_data_obj.get_default_dir(self)
if os.path.isdir(container_dir):
self.remove_directory(container_dir)
# If emptying the container rather than deleting it, just create a
# replacement (empty) directory on the filesystem
if empty_flag:
self.make_directory(container_dir)
# Now call self.delete_container_complete() to handle the media data
# registry
@ -14185,8 +14251,8 @@ class TartubeApp(Gtk.Application):
def delete_container_complete(self, media_data_obj, empty_flag,
recursive_flag=False):
"""Called by self.delete_container() and .delete_container_continue().
Subsequently called by this function recursively.
"""Called by self.delete_container_continue(). Subsequently called by
this function recursively.
Deletes a channel, playlist or folder object from the media data
registry.
@ -16482,6 +16548,10 @@ class TartubeApp(Gtk.Application):
del self.media_unavailable_dict[old_name]
self.media_unavailable_dict[new_name] = media_data_obj.dbid
# Update the IV which keeps track of Video Index markers, as it
# stores the container's name as a key
self.main_win_obj.video_index_update_marker(old_name, new_name)
# Reset the Video Index and the Video Catalogue (this prevents a
# lot of problems)
self.main_win_obj.video_index_catalogue_reset()
@ -16550,6 +16620,10 @@ class TartubeApp(Gtk.Application):
del self.media_unavailable_dict[old_name]
self.media_unavailable_dict[new_name] = media_data_obj.dbid
# Update the IV which keeps track of Video Index markers, as it stores
# the container's name as a key
self.main_win_obj.video_index_update_marker(old_name, new_name)
return True
@ -20656,6 +20730,24 @@ class TartubeApp(Gtk.Application):
self.main_win_obj.video_catalogue_apply_filter()
def on_button_cancel_date(self, action, par):
"""Called from a callback in self.do_startup().
Changes the Video Catalogue page to the first one, after showing a page
matching a particular date.
Args:
action (Gio.SimpleAction): Object generated by Gio
par (None): Ignored
"""
self.main_win_obj.video_catalogue_unshow_date()
def on_button_cancel_error_filter(self, action, par):
"""Called from a callback in self.do_startup().
@ -20699,6 +20791,37 @@ class TartubeApp(Gtk.Application):
self.main_win_obj.video_catalogue_cancel_filter()
def on_button_check_all(self, action, par):
"""Called from a callback in self.do_startup().
Call a function to start a new download operation (if allowed).
Unlike the corresponding self.on_menu_check_all button, this function
will check only the marked items, if any.
Args:
action (Gio.SimpleAction): Object generated by Gio
par (None): Ignored
"""
media_list = []
for name in self.main_win_obj.video_index_marker_dict.keys():
if name in self.media_name_dict:
dbid = self.media_name_dict[name]
if dbid in self.media_reg_dict:
media_list.append(self.media_reg_dict[dbid])
self.download_manager_start(
'sim',
False, # Not called from self.script_slow_timer_callback()
media_list, # May be empty, in which case everything is checked
)
def on_button_classic_add_urls(self, action, par):
"""Called from a callback in self.do_startup().
@ -20718,6 +20841,27 @@ class TartubeApp(Gtk.Application):
self.main_win_obj.classic_mode_tab_add_urls()
def on_button_classic_archive(self, action, par):
"""Called from a callback in self.do_startup().
Enables/disables the youtube-dl archive file in downloads from the
Classic Mode tab.
Args:
action (Gio.SimpleAction): Object generated by Gio
par (None): Ignored
"""
if self.main_win_obj.classic_archive_button.get_active():
self.classic_ytdl_archive_flag = True
else:
self.classic_ytdl_archive_flag = False
def on_button_classic_dest_dir(self, action, par):
"""Called from a callback in self.do_startup().
@ -21146,13 +21290,6 @@ class TartubeApp(Gtk.Application):
# Delete the files associated with the video
self.delete_video_files(video_obj)
# If mainapp.TartubeApp.allow_ytdl_archive_flag is set,
# youtube-dl will have created a ytdl_archive.txt, recording
# every video ever downloaded in the parent directory. This
# will prevent a successful re-downloading of the video
# Temporarily block usage of the archive file
self.block_ytdl_archive_flag = True
# Start the download operation
if not self.classic_custom_dl_flag:
self.download_manager_start('classic_real', False, video_list)
@ -21287,12 +21424,15 @@ class TartubeApp(Gtk.Application):
worker_obj.downloader_obj.stop()
def on_button_cancel_date(self, action, par):
def on_button_custom_dl_all(self, action, par):
"""Called from a callback in self.do_startup().
Changes the Video Catalogue page to the first one, after showing a page
matching a particular date.
Call a function to start a new (custom) download operation (if
allowed).
Unlike the corresponding self.on_menu_custom_dl_all button, this
function will custom download only the marked items, if any.
Args:
@ -21302,7 +21442,62 @@ class TartubeApp(Gtk.Application):
"""
self.main_win_obj.video_catalogue_unshow_date()
media_list = []
for name in self.main_win_obj.video_index_marker_dict.keys():
if name in self.media_name_dict:
dbid = self.media_name_dict[name]
if dbid in self.media_reg_dict:
media_list.append(self.media_reg_dict[dbid])
if not self.general_custom_dl_obj.dl_by_video_flag \
or not self.general_custom_dl_obj.dl_precede_flag:
self.download_manager_start(
'custom_real',
False, # Not called by the timer
media_list, # Download all media data objects
self.general_custom_dl_obj,
)
else:
self.download_manager_start(
'custom_sim',
False, # Not called by the timer
media_list, # Download all media data objects
self.general_custom_dl_obj,
)
def on_button_download_all(self, action, par):
"""Called from a callback in self.do_startup().
Call a function to start a new download operation (if allowed).
Unlike the corresponding self.on_menu_download_all button, this
function will download only the marked items, if any.
Args:
action (Gio.SimpleAction): Object generated by Gio
par (None): Ignored
"""
media_list = []
for name in self.main_win_obj.video_index_marker_dict.keys():
if name in self.media_name_dict:
dbid = self.media_name_dict[name]
if dbid in self.media_reg_dict:
media_list.append(self.media_reg_dict[dbid])
self.download_manager_start(
'real',
False, # Not called from self.script_slow_timer_callback()
media_list, # May be empty, in which case everything is downloaded
)
def on_button_drag_drop_add(self, action, par):
@ -22604,6 +22799,23 @@ class TartubeApp(Gtk.Application):
self.main_win_obj.custom_dl_popup_menu()
def on_menu_unmark_all(self, action, par):
"""Called from a callback in self.do_startup().
Unmarks all markers in the Video Index.
Args:
action (Gio.SimpleAction): Object generated by Gio
par (None): Ignored
"""
self.main_win_obj.video_index_reset_marker()
def on_menu_download_all(self, action, par):
"""Called from a callback in self.do_startup().
@ -23211,14 +23423,16 @@ class TartubeApp(Gtk.Application):
'exist_flag': dialogue_win.checkbutton3.get_active(),
'del_video_flag': dialogue_win.checkbutton4.get_active(),
'del_others_flag': dialogue_win.checkbutton5.get_active(),
'del_archive_flag': dialogue_win.checkbutton6.get_active(),
'move_thumb_flag': dialogue_win.checkbutton7.get_active(),
'del_thumb_flag': dialogue_win.checkbutton8.get_active(),
'convert_webp_flag': dialogue_win.checkbutton9.get_active(),
'move_data_flag': dialogue_win.checkbutton10.get_active(),
'del_descrip_flag': dialogue_win.checkbutton11.get_active(),
'del_json_flag': dialogue_win.checkbutton12.get_active(),
'del_xml_flag': dialogue_win.checkbutton13.get_active(),
'remove_no_url_flag': dialogue_win.checkbutton6.get_active(),
'remove_dupe_flag': dialogue_win.checkbutton7.get_active(),
'del_archive_flag': dialogue_win.checkbutton8.get_active(),
'move_thumb_flag': dialogue_win.checkbutton9.get_active(),
'del_thumb_flag': dialogue_win.checkbutton10.get_active(),
'convert_webp_flag': dialogue_win.checkbutton11.get_active(),
'move_data_flag': dialogue_win.checkbutton12.get_active(),
'del_descrip_flag': dialogue_win.checkbutton13.get_active(),
'del_json_flag': dialogue_win.checkbutton14.get_active(),
'del_xml_flag': dialogue_win.checkbutton15.get_active(),
}
# Now destroy the window
@ -23227,28 +23441,23 @@ class TartubeApp(Gtk.Application):
if response == Gtk.ResponseType.OK:
# If nothing was selected, then there is nothing to do
# (Don't need to check 'del_others_flag' here)
if not choices_dict['corrupt_flag'] \
and not choices_dict['exist_flag'] \
and not choices_dict['del_video_flag'] \
and not choices_dict['del_thumb_flag'] \
and not choices_dict['convert_webp_flag'] \
and not choices_dict['del_descrip_flag'] \
and not choices_dict['del_json_flag'] \
and not choices_dict['del_xml_flag'] \
and not choices_dict['del_archive_flag'] \
and not choices_dict['move_thumb_flag'] \
and not choices_dict['move_data_flag']:
selected_flag = False
for key in choices_dict.keys():
if choices_dict[key]:
selected_flag = True
break
if not selected_flag:
return
# Prompt the user for confirmation, before deleting any files
if choices_dict['del_corrupt_flag'] \
or choices_dict['del_video_flag'] \
or choices_dict['del_archive_flag'] \
or choices_dict['del_thumb_flag'] \
or choices_dict['del_descrip_flag'] \
or choices_dict['del_json_flag'] \
or choices_dict['del_xml_flag'] \
or choices_dict['del_archive_flag']:
or choices_dict['del_xml_flag']:
self.dialogue_manager_obj.show_msg_dialogue(
_(
@ -23654,14 +23863,6 @@ class TartubeApp(Gtk.Application):
self.bandwidth_default = value
def set_block_ytdl_archive_flag(self, flag):
if not flag:
self.block_ytdl_archive_flag = False
else:
self.block_ytdl_archive_flag = True
def set_catalogue_draw_blocked_flag(self, flag):
if not flag:
@ -24789,6 +24990,19 @@ class TartubeApp(Gtk.Application):
)
def set_show_marker_in_index_flag(self, flag):
if not flag:
self.show_marker_in_index_flag = False
else:
self.show_marker_in_index_flag = True
# Reset all markers in the Video Index
self.main_win_obj.video_index_reset_marker()
# Redraw the Video Index and Video Catalogue
self.main_win_obj.video_index_catalogue_reset()
def set_show_small_icons_in_index_flag(self, flag):
if not flag:

File diff suppressed because it is too large Load Diff

View File

@ -1975,6 +1975,18 @@ class Video(GenericMedia):
# Valid values are those specified by formats.VIDEO_FORMAT_LIST,
# formats.AUDIO_FORMAT_LIST and formats.VIDEO_RESOLUTION_LIST
self.dummy_format = None
# Flag set to True if the download was completed, in which case
# self.source is not added to mainapp.TartubeApp.classic_pending_list
# (remembering it for the next session)
# Specifically, it remains False when the download is waiting to start,
# or if the VideoDownloader returns a return value of STOPPED, or
# if (during a download) self.dummy_path is still None, meaning no
# videos have been downloaded
# Once set to True, it is never set back to False. So, if the user
# tries to re-download a channel/playlist and no new videos are
# found, the flag remains set to True
self.dummy_dl_flag = False
# Code
# ----
@ -2179,7 +2191,7 @@ class Video(GenericMedia):
self.set_video_descrip(app_obj, text, max_length)
def extract_timestamps_from_descrip(self, app_obj):
def extract_timestamps_from_descrip(self, app_obj, override_descrip=None):
"""Can be called by anything. Often called by
self.set_video_descrip().
@ -2200,16 +2212,24 @@ class Video(GenericMedia):
app_obj (mainapp.TartubeApp): The main application
override_descrip (str or None): If specified, extract timestamps
from this string, rather than from self.descrip
"""
if self.descrip is None or self.descrip == '':
if (self.descrip is None or self.descrip == '') \
and (override_descrip is None or override_descrip == ''):
return
regex = r'^\s*(' + app_obj.timestamp_regex + r')(\s.*)'
rev_regex = r'^(.*\s)(' + app_obj.timestamp_regex + r')\s*$'
digit_count = 0
line_list = self.descrip.split('\n')
if override_descrip is not None and override_descrip != '':
line_list = override_descrip.split('\n')
else:
line_list = self.descrip.split('\n')
temp_list = []
stamp_list = []
@ -2677,6 +2697,14 @@ class Video(GenericMedia):
self.source = url
def set_dummy_dl_flag(self, flag):
if flag:
self.dummy_dl_flag = True
else:
self.dummy_dl_flag = False
def set_dummy_path(self, path):
self.dummy_path = path

File diff suppressed because it is too large Load Diff

View File

@ -167,24 +167,42 @@ class ProcessManager(threading.Thread):
+ str(self.job_total) + ': ' + video_obj.name,
)
if self.options_obj.options_dict['output_mode'] == 'split':
default_flag = False
if self.app_obj.temp_stamp_list:
# Split the video into video clips
# Split the video into video clips, using the .stamp_list
# specified directly by the user (instead of the one
# specified by the media.Video object)
dest_dir = self.split_video(video_obj)
if self.fatal_error_flag:
break
else:
# Add the returned destination directory to a list,
# first checking for duplicates
if not dest_dir in check_dict:
dest_dir_list.append(dest_dir)
check_dict[dest_dir] = None
elif self.app_obj.temp_slice_list:
# Produce a single output video with slices removed, using the
# .slice_list specified directly by the user (instead of the
# one specified by the media.Video object)
dest_dir = self.slice_video(video_obj)
elif self.options_obj.options_dict['output_mode'] == 'split':
# Split the video into video clips, using the .stamp_list
# specified by the media.Video object
dest_dir = self.split_video(video_obj)
elif self.options_obj.options_dict['output_mode'] == 'slice':
# Produce a single output video with slices removed
# Produce a single output video with slices removed, using the
# .slice_list specified by the media.Video object
dest_dir = self.slice_video(video_obj)
else:
# Process the video with FFmpeg. One source video produces one
# output video
self.process_video(video_obj)
default_flag = True
if not default_flag:
if self.fatal_error_flag:
# This is a fatal error
break
@ -196,12 +214,6 @@ class ProcessManager(threading.Thread):
dest_dir_list.append(dest_dir)
check_dict[dest_dir] = None
else:
# Process the video with FFmpeg. One source video produces one
# output video
self.process_video(video_obj)
# Pause a moment, before the next iteration of the loop (don't want
# to hog resources)
time.sleep(self.sleep_time)
@ -333,7 +345,7 @@ class ProcessManager(threading.Thread):
def process_video(self, orig_video_obj, dest_dir=None, start_point=None, \
stop_point=None, clip_title=None):
stop_point=None, clip_title=None, override_output_mode=None):
"""Called by self.run(), .slice_video() and .split_video().
@ -359,6 +371,12 @@ class ProcessManager(threading.Thread):
clip_title (str): When splitting a video, the title of this video
clip (if specified)
override_output_mode (str): When splitting/slicing a video, and the
user has specified their own .stamp_list or .slice_list, then
this value is set to 'split' or 'slice', overriding the
'output_mode' of the FFmpegOptionsManager. Otherwise set to
None
Return values:
True of success, False on failure
@ -386,14 +404,30 @@ class ProcessManager(threading.Thread):
# Get the source/output files, ahd the full FFmpeg system command (as a
# list, and including the source/output files)
source_path, output_path, cmd_list = self.options_obj.get_system_cmd(
self.app_obj,
orig_video_obj,
start_point,
stop_point,
clip_title,
dest_dir,
)
if override_output_mode is None:
source_path, output_path, cmd_list \
= self.options_obj.get_system_cmd(
self.app_obj,
orig_video_obj,
start_point,
stop_point,
clip_title,
dest_dir,
)
else:
source_path, output_path, cmd_list \
= self.options_obj.get_system_cmd(
self.app_obj,
orig_video_obj,
start_point,
stop_point,
clip_title,
dest_dir,
{ 'output_mode': override_output_mode },
)
if source_path is None:
@ -567,8 +601,11 @@ class ProcessManager(threading.Thread):
)
# Import the correct slice list
override_output_mode = None
if self.app_obj.temp_slice_list:
override_output_mode = 'slice'
# Use the temporary buffer
slice_list = self.app_obj.temp_slice_list.copy()
temp_flag = True
@ -661,6 +698,7 @@ class ProcessManager(threading.Thread):
start_time,
stop_time,
'clip_' + str(i + 1), # Clip title
override_output_mode,
):
# Don't continue creating more clips after an error
self.fatal_error_flag = True
@ -825,8 +863,11 @@ class ProcessManager(threading.Thread):
return None
# Import the correct timestamp list
override_output_mode = None
if self.app_obj.temp_stamp_list:
override_output_mode = 'split'
# Use the temporary buffer
stamp_list = self.app_obj.temp_stamp_list.copy()
# (The temporary buffer, once used, must be emptied immediately)
@ -899,6 +940,7 @@ class ProcessManager(threading.Thread):
start_stamp,
stop_stamp,
clip_title,
override_output_mode,
):
# Don't continue creating more clips after an error
self.fatal_error_flag = True

View File

@ -42,8 +42,8 @@ import mainapp
# 'Global' variables
__packagename__ = 'tartube'
__version__ = '2.3.518'
__date__ = '5 Apr 2022'
__version__ = '2.3.549'
__date__ = '9 Apr 2022'
__copyright__ = 'Copyright \xa9 2019-2022 A S Lewis'
__license__ = """
Copyright \xa9 2019-2022 A S Lewis.

View File

@ -83,6 +83,16 @@ class TidyManager(threading.Thread):
should be deleted (as artefacts of post-processing with FFmpeg
or AVConv)
remove_no_url_flag: True if any media.Video objects whose URL is
not set should be removed from the database (no files are
deleted)
remove_dupe_flag: True if any media.Video objects, which are not
marked as downloaded and which share a URL with another
media.Video object with the same parent and which is marked as
downloaded, should be removed from the database (no files are
deleted)
del_archive_flag: True if all youtube-dl archive files should be
deleted
@ -151,6 +161,14 @@ class TidyManager(threading.Thread):
# True if all video/audio files with the same name should be deleted
# (as artefacts of post-processing with FFmpeg or AVConv)
self.del_others_flag = choices_dict['del_others_flag']
# True if any media.Video objects whose URL is not set should be
# removed from the database (no files are deleted)
self.remove_no_url_flag = choices_dict['remove_no_url_flag']
# True if any media.Video objects, which are not marked as downloaded
# and which share a URL with another media.Video object with the same
# parent and which is marked as downloaded, should be removed from
# the database (no files are deleted)
self.remove_dupe_flag = choices_dict['remove_dupe_flag']
# True if all youtube-dl archive files should be deleted
self.del_archive_flag = choices_dict['del_archive_flag']
# True if all thumbnail files should be moved into a subdirectory
@ -184,6 +202,8 @@ class TidyManager(threading.Thread):
self.video_no_exist_count = 0
self.video_deleted_count = 0
self.other_deleted_count = 0
self.remove_no_url_count = 0
self.remove_dupe_count = 0
self.archive_deleted_count = 0
self.thumb_moved_count = 0
self.thumb_deleted_count = 0
@ -295,6 +315,27 @@ class TidyManager(threading.Thread):
' ' + _('Delete other video/audio files:') + ' ' + text,
)
if self.remove_no_url_flag:
text = _('YES')
else:
text = _('NO')
self.app_obj.main_win_obj.output_tab_write_stdout(
1,
' ' + _('Remove no_URL videos from database:') + ' ' + text,
)
if self.remove_dupe_flag:
text = _('YES')
else:
text = _('NO')
self.app_obj.main_win_obj.output_tab_write_stdout(
1,
' ' + _('Remove undownloaded duplicate videos from database:') \
+ ' ' + text,
)
if self.del_archive_flag:
text = _('YES')
else:
@ -454,6 +495,23 @@ class TidyManager(threading.Thread):
+ str(self.other_deleted_count),
)
if self.remove_no_url_flag:
self.app_obj.main_win_obj.output_tab_write_stdout(
1,
' ' + _('No-URL videos removed from database:') + ' ' \
+ str(self.remove_no_url_count),
)
if self.remove_dupe_flag:
self.app_obj.main_win_obj.output_tab_write_stdout(
1,
' ' \
+ _('Undownloaded duplicate videos removed from database:') \
+ ' ' + str(self.remove_dupe_count),
)
if self.del_archive_flag:
self.app_obj.main_win_obj.output_tab_write_stdout(
@ -564,6 +622,12 @@ class TidyManager(threading.Thread):
if self.del_video_flag:
self.delete_video(media_data_obj)
if self.remove_no_url_flag:
self.remove_no_url(media_data_obj)
if self.remove_dupe_flag:
self.remove_dupe(media_data_obj)
if self.del_archive_flag:
self.delete_archive(media_data_obj)
@ -826,6 +890,81 @@ class TidyManager(threading.Thread):
self.other_deleted_count += 1
def remove_no_url(self, media_data_obj):
"""Called by self.tidy_directory().
Checks all child videos of the specified media data object. If the
video has no URL, remove it from the database (but don't delete any
files).
Args:
media_data_obj (media.Channel, media.Playlist or media.Folder):
The media data object whose directory must be tidied up
"""
for video_obj in media_data_obj.compile_all_videos( [] ):
if video_obj.source is None:
GObject.timeout_add(
0,
self.app_obj.delete_video,
video_obj,
)
self.remove_no_url_count += 1
def remove_dupe(self, media_data_obj):
"""Called by self.tidy_directory().
Checks all child videos of the specified media data object. If the
video is not marked as downloaded, and has the same URL as another
child video (of the same specified media data object) which IS marked
as downloaded, remove the undownloaded one from the database (but
don't delete any files).
Args:
media_data_obj (media.Channel, media.Playlist or media.Folder):
The media data object whose directory must be tidied up
"""
# Compile dictionaries of downloaded and undownloaded URLs
dl_dict = {}
not_dl_dict = {}
for video_obj in media_data_obj.compile_all_videos( [] ):
if video_obj.source is not None:
if video_obj.dl_flag:
dl_dict[video_obj.source] = video_obj.dbid
else:
not_dl_dict[video_obj.source] = video_obj.dbid
# Check undownloaded videos, looking for a matching downloaded video
for url in not_dl_dict.keys():
if url in dl_dict:
# Duplicate found
dbid = not_dl_dict[url]
if dbid in self.app_obj.media_reg_dict:
duplicate_obj = self.app_obj.media_reg_dict[dbid]
GObject.timeout_add(
0,
self.app_obj.delete_video,
duplicate_obj,
)
self.remove_dupe_count += 1
def delete_archive(self, media_data_obj):
"""Called by self.tidy_directory().

View File

@ -21,7 +21,7 @@
# Import Gtk modules
from gi.repository import Gtk, Gdk
from gi.repository import Gtk, Gdk, GObject
# Import other modules
@ -1968,11 +1968,8 @@ custom_dl_obj=None, divert_mode=None):
# We don't use an archive file when downloading into a system folder,
# unless a non-default location for the file has been specified
if (
not app_obj.block_ytdl_archive_flag \
and (
not dl_classic_flag and app_obj.allow_ytdl_archive_flag \
or dl_classic_flag and app_obj.classic_ytdl_archive_flag
)
(not dl_classic_flag and app_obj.allow_ytdl_archive_flag) \
or (dl_classic_flag and app_obj.classic_ytdl_archive_flag)
):
if not dl_classic_flag \
and (
@ -3198,7 +3195,7 @@ def tidy_up_container_name(app_obj, string, max_length):
return ''
for illegal in app_obj.illegal_name_mswin_list:
if re.search('^' + illegal + '\.'):
if re.search('^' + illegal + '\.', string):
return ''
# Forbidden characters on MS Windows: < > : " / \ | ? *