tartube/tartube/utils.py

1202 lines
31 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019-2020 A S Lewis
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
"""Utility functions used by code copied from youtube-dl-gui."""
# Import Gtk modules
from gi.repository import Gtk, Gdk
# Import other modules
import datetime
import locale
import math
import os
import re
import requests
import shutil
import subprocess
import sys
import textwrap
# Import our modules
import formats
import mainapp
import media
# Functions
def add_links_to_entry_from_clipboard(app_obj, entry, duplicate_text=None,
drag_drop_text=None, no_modify_flag=None):
"""Called by various functions in mainWin.AddChannelDialogue and
mainwin.AddPlaylistDialogue.
Function to add valid URLs from the clipboard to a Gtk.Entry, ignoring
anything that is not a valid URL.
A duplicate URL can be specified, when the dialogue window's clipboard
monitoring is turned on; it prevents this function adding the same URL
that was added the previous time.
Args:
app_obj (mainapp.TartubeApp): The main application
entry (Gtk.Entry): The entry to which valis URLs should be added.
Only the first valid URL is added, replacing any previous contents
(unless the URL matches the specified duplicate
duplicate_text (str): If specified, ignore the clipboard contents, if
it matches this URL
drag_drop_text (str): If specified, use this text and ignore the
clipboard
no_modify_flag (bool): If True, the entry is not updated, instead,
the URL that would have been added to it is merely returned
Returns:
The URL added to the entry (or that would have been added to the entry)
or None if no valid and non-duplicate URL was found in the clipboard
"""
if drag_drop_text is None:
# Get text from the system clipboard
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
cliptext = clipboard.wait_for_text()
else:
# Ignore the clipboard, and use the specified text
cliptext = drag_drop_text
# Eliminate empty lines and any lines that are not valid URLs (we assume
# that it's one URL per line)
# Use the first valid line that doesn't match the duplicate (if specified)
if cliptext is not None and cliptext != Gdk.SELECTION_CLIPBOARD:
for line in cliptext.split('\n'):
if check_url(line):
line = strip_whitespace(line)
if re.search('\S', line) \
and (duplicate_text is None or line != duplicate_text):
if not no_modify_flag:
entry.set_text(line)
return line
# No valid and non-duplicate URL found
return None
def add_links_to_textview_from_clipboard(app_obj, textview, mark_start=None,
mark_end=None, drag_drop_text=None):
"""Called by mainwin.AddVideoDialogue.__init__(),
.on_window_drag_data_received() and .clipboard_timer_callback().
Function to add valid URLs from the clipboard to a Gtk.TextView, ignoring
anything that is not a valid URL, and ignoring duplicate URLs.
If some text is supplied as an argument, uses that text rather than the
clipboard text
Args:
app_obj (mainapp.TartubeApp): The main application
textview (Gtk.TextBuffer): The textview to which valis URLs should be
added (unless they are duplicates)
mark_start, mark_end (Gtk.TextMark): The marks at the start/end of the
buffer (using marks rather than iters prevents Gtk errors)
drag_drop_text (str): If specified, use this text and ignore the
clipboard
"""
if drag_drop_text is None:
# Get text from the system clipboard
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
cliptext = clipboard.wait_for_text()
else:
# Ignore the clipboard, and use the specified text
cliptext = drag_drop_text
# Eliminate empty lines and any lines that are not valid URLs (we assume
# that it's one URL per line)
# At the same time, trim initial/final whitespace
valid_list = []
if cliptext is not None and cliptext != Gdk.SELECTION_CLIPBOARD:
for line in cliptext.split('\n'):
if check_url(line):
line = strip_whitespace(line)
if re.search('\S', line):
valid_list.append(line)
if valid_list:
# Some URLs survived the cull
# Get the contents of the buffer
if mark_start is None or mark_end is None:
# No Gtk.TextMarks supplied, we're forced to use iters
buffer_text = textview.get_text(
textview.get_start_iter(),
textview.get_end_iter(),
# Don't include hidden characters
False,
)
else:
buffer_text = textview.get_text(
textview.get_iter_at_mark(mark_start),
textview.get_iter_at_mark(mark_end),
False,
)
# Remove any URLs that already exist in the buffer
line_list = buffer_text.split('\n')
mod_list = []
for line in valid_list:
if not line in line_list:
mod_list.append(line)
# Add any surviving URLs to the buffer, first adding a newline
# character, if the buffer doesn't end in one
if mod_list:
if not re.search('\n\s*$', buffer_text) and buffer_text != '':
mod_list[0] = '\n' + mod_list[0]
textview.insert(
textview.get_end_iter(),
str.join('\n', mod_list) + '\n',
)
def check_url(url):
"""Can be called by anything.
Checks for valid URLs.
Args:
url (str): The URL to check
Returns:
True if the URL is valid, False if invalid.
"""
prepared_request = requests.models.PreparedRequest()
try:
prepared_request.prepare_url(url, None)
# The requests module allows a lot of URLs that are definitely not of
# interest to us
# This filter seems to catch most of the gibberish (although it's not
# perfect)
if re.search('^\S+\.\S', url) \
or re.search('localhost', url):
return True
else:
return False
except:
return False
def convert_item(item, to_unicode=False):
"""Can be called by anything.
Based on the convert_item() function in youtube-dl-gui.
Convert item between 'unicode' and 'str'.
Args:
item (-): Can be any python item
to_unicode (bool): When True it will convert all the 'str' types to
'unicode'. When False it will convert all the 'unicode' types back
to 'str'
Returns:
The converted item
"""
if to_unicode and isinstance(item, str):
# Convert str to unicode
return item.decode(get_encoding(), 'ignore')
if not to_unicode and isinstance(item, unicode):
# Convert unicode to str
return item.encode(get_encoding(), 'ignore')
if hasattr(item, '__iter__'):
# Handle iterables
temp_list = []
for sub_item in item:
if isinstance(item, dict):
temp_list.append(
(
convert_item(sub_item, to_unicode),
convert_item(item[sub_item], to_unicode),
)
)
else:
temp_list.append(convert_item(sub_item, to_unicode))
return type(item)(temp_list)
return item
def convert_path_to_temp(app_obj, old_path, move_flag=False):
"""Can be called by anything.
Converts a full path to a file that would be stored in Tartube's data
directory (mainapp.TartubeApp.downloads_dir) into the equivalent path in
Tartube's temporary directory (mainapp.TartubeApp.temp_dl_dir).
Optionally moves a file from one location to the other.
Regardless of whether the file is moved or not, creates the destination
sub-directory if it doesn't already exist, and deletes the destination file
if it already exists (both of which prevent exceptions being raised).
Args:
app_obj (mainapp.TartubeApp): The main application
old_path (str): Full path to the existing file
move_flag (bool): If True, the file is actually moved to the new
location
Returns:
new_path: The converted full file path
"""
data_dir_len = len(app_obj.downloads_dir)
new_path = app_obj.temp_dl_dir + old_path[data_dir_len:]
new_dir, new_filename = os.path.split(new_path.strip("\""))
# The destination folder must exist, before moving files into it
if not os.path.exists(new_dir):
os.makedirs(new_dir)
# On MS Windows, a file name new_path must not exist, or an exception will
# be raised
if os.path.isfile(new_path):
os.remove(new_path)
# Move the file now, if the calling code requires that
if move_flag:
# (On MSWin, can't do os.rename if the destination file already exists)
if os.path.isfile(new_path):
os.remove(new_path)
# (os.rename sometimes fails on external hard drives; this is safer)
shutil.move(old_path, new_path)
# Return the converted file path
return new_path
def convert_seconds_to_string(seconds, short_flag=False):
"""Can be called by anything.
Converts a time in seconds into a formatted string.
Args:
seconds (int or float): The time to convert
short_flag (bool): If True, show '05:15' rather than '0:05:15'
Returns:
The converted string, e.g. '05:12' or '16:05:12'
"""
# Round up fractional seconds
if seconds is not None:
if seconds != int(seconds):
seconds = int(seconds) + 1
else:
seconds = 1
if short_flag and seconds < 3600:
# When required, show 05:15 rather than 0:05:15
minutes = int(seconds / 60)
seconds = int(seconds % 60)
return '{:02d}:{:02d}'.format(minutes, seconds)
else:
return str(datetime.timedelta(seconds=seconds))
def convert_youtube_to_hooktube(url):
"""Can be called by anything.
Converts a YouTube weblink to a HookTube weblink (but doesn't modify links
to other sites.
Args:
url (str): The weblink to convert
Returns:
The converted string
"""
if re.search(r'^https?:\/\/(www)+\.youtube\.com', url):
url = re.sub(
r'youtube\.com',
'hooktube.com',
url,
# Substitute first occurence only
1,
)
return url
def convert_youtube_to_invidious(url):
"""Can be called by anything.
Converts a YouTube weblink to an Invidious weblink (but doesn't modify
links to other sites.
Args:
url (str): The weblink to convert
Returns:
The converted string
"""
if re.search(r'^https?:\/\/(www)+\.youtube\.com', url):
url = re.sub(
r'youtube\.com',
'invidio.us',
url,
# Substitute first occurence only
1,
)
return url
def debug_time(msg):
"""Called by all functions in downloads.py, info.py, mainapp.py,
mainwin.py, refresh.py, tidy.py and updates.py.
Writes the current time, and the name of the calling function to STDOUT,
e.g. '2020-01-16 08:55:06 ap 91 __init__'.
Args:
msg (str): The message to write
"""
# Uncomment this code to display the time with microseconds
# print(str(datetime.datetime.now().time()) + ' ' + msg)
# Uncomment this code to display the time without microseconds
dt = datetime.datetime.now()
print(str(dt.replace(microsecond=0)) + ' ' + msg)
# Uncomment this code to display the message, without a timestamp
# print(msg)
# This line makes my IDE collapse functions nicely
return
def disk_get_free_space(path, bytes_flag=False):
"""Can be called by anything.
Returns the size of the disk on which a specified file/directory exists,
minus the used space on that disk.
Args:
path (str): Path to a file/directory on the disk, typically Tartube's
data directory
bytes_flag (bool): True to return an integer value in MB, false to
return a value in bytes
Returns:
The free space in MB (or in bytes, if the flag is specified), or 0 if
the size can't be calculated for any reason
"""
try:
total_bytes, used_bytes, free_bytes = shutil.disk_usage(
os.path.realpath(path),
)
if not bytes_flag:
return int(free_bytes / 1000000)
else:
return free_bytes
except:
return 0
def disk_get_total_space(path, bytes_flag=False):
"""Can be called by anything.
Returns the size of the disk on which a specified file/directory exists.
Args:
path (str): Path to a file/directory on the disk, typically Tartube's
data directory
bytes_flag (bool): True to return an integer value in MB, false to
return a value in bytes
Returns:
The total size in MB (or in bytes, if the flag is specified)
"""
total_bytes, used_bytes, free_bytes = shutil.disk_usage(
os.path.realpath(path),
)
if not bytes_flag:
return int(total_bytes / 1000000)
else:
return total_bytes
def disk_get_used_space(path, bytes_flag=False):
"""Can be called by anything.
Returns the size of the disk on which a specified file/directory exists,
minus the free space on that disk.
Args:
path (str): Path to a file/directory on the disk, typically Tartube's
data directory
bytes_flag (bool): True to return an integer value in MB, false to
return a value in bytes
Returns:
The used space in MB (or in bytes, if the flag is specified)
"""
total_bytes, used_bytes, free_bytes = shutil.disk_usage(
os.path.realpath(path),
)
if not bytes_flag:
return int(used_bytes / 1000000)
else:
return used_bytes
def find_available_name(app_obj, old_name, min_value=2, max_value=9999):
"""Can be called by anything.
mainapp.TartubeApp.media_name_dict stores the names of all media.Channel,
media.Playlist and media.Folder objects as keys.
old_name is the name of an existing media data object. This function
slightly modifies the name, converting 'my_name' into 'my_name_N', where N
is the smallest positive integer for which the name is available.
To preclude any possibility of infinite loops, the function will give up
after max_value attempts.
Args:
app_obj (mainapp.TartubeApp): The main application
old_name (str): The name which is already in use by a media data object
min_value (str): The first name to try. 2 by default, so the first
name checked will be 'my_name_2'
max_value (int): When to give up. 9999 by default, meaning that this
function will try everything up to 'my_name_9999' before giving up.
If set to -1, this function never gives up
Returns:
None on failure, the new name on success
"""
if max_value != -1:
for n in range (min_value, max_value):
new_name = old_name + '_' + str(n)
if not new_name in app_obj.media_name_dict:
return new_name
# Failure
return None
else:
# Renaming is essential, for example, in calls from
# mainapp.TartubeApp.load_db(). Keep going indefinitely until an
# available name is found
n = 1
while 1:
n += 1
new_name = old_name + '_' + str(n)
if not new_name in app_obj.media_name_dict:
return new_name
def find_thumbnail(app_obj, video_obj, temp_dir_flag=False):
"""Can be called by anything.
No way to know which image format is used by all websites for their video
thumbnails, so look for the most common ones, and return the path to the
thumbnail file if one is found.
Args:
app_obj (mainapp.TartubeApp): The main application
video_obj (media.Video): The video object handling the downloaded video
temp_dir_flag (bool): If True, this function will look in Tartube's
temporary data directory, if the thumbnail isn't found in the main
data directory
Returns:
path (str): The full path to the thumbnail file, or None
"""
for ext in ('.jpg', '.png', '.gif'):
# Look in Tartube's permanent data directory
path = video_obj.get_actual_path_by_ext(app_obj, ext)
if os.path.isfile(path):
return path
elif temp_dir_flag:
# Look in temporary data directory
data_dir_len = len(app_obj.downloads_dir)
temp_path = app_obj.temp_dl_dir + path[data_dir_len:]
if os.path.isfile(temp_path):
return temp_path
return None
def format_bytes(num_bytes):
"""Can be called by anything.
Based on the format_bytes() function in youtube-dl-gui.
Convert bytes into a formatted string, e.g. '23.5GiB'.
Args:
num_bytes (float): The number to convert
Returns:
The formatted string
"""
if num_bytes == 0.0:
exponent = 0
else:
exponent = int(math.log(num_bytes, formats.KILO_SIZE))
suffix = formats.FILESIZE_METRIC_LIST[exponent]
output_value = num_bytes / (formats.KILO_SIZE ** exponent)
return "%.2f%s" % (output_value, suffix)
def generate_system_cmd(app_obj, media_data_obj, options_list,
dl_sim_flag=False, divert_mode=None):
"""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
divert_mode (str): If not None, should be one of the values of
mainapp.TartubeApp.custom_dl_divert_mode: 'default', 'hooktube' or
'invidious'. If one of the latter two, a media.Video object whose
source URL points to YouTube should be converted to HookTube or
Invidious (no conversion takes place for channels/playlists/
folders)
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
# (Videos downloaded into a system folder should never create an archive
# file)
if app_obj.allow_ytdl_archive_flag \
and (
not isinstance(media_data_obj, media.Folder)
or not media_data_obj.fixed_flag
) and (
not isinstance(media_data_obj, media.Video)
or not isinstance(media_data_obj.parent_obj, media.Folder)
or not media_data_obj.parent_obj.fixed_flag
):
# (Create the archive file in the media data object's default
# sub-directory, not the alternative download destination, as this
# helps youtube-dl to work the way we want it to work)
if isinstance(media_data_obj, media.Video):
dl_path = media_data_obj.parent_obj.get_default_dir(app_obj)
else:
dl_path = media_data_obj.get_default_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 + '"')
# Convert a YouTube URL to HookTube/Invidious, if required
source = media_data_obj.source
if isinstance(media_data_obj, media.Video) and divert_mode:
if divert_mode == 'hooktube':
source = convert_youtube_to_hooktube(source)
elif divert_mode == 'invidious':
source = convert_youtube_to_invidious(source)
# Convert a path beginning with ~ (not on MS Windows)
ytdl_path = app_obj.ytdl_path
if os.name != 'nt':
ytdl_path = re.sub('^\~', os.path.expanduser('~'), ytdl_path)
# Set the list
cmd_list = [ytdl_path] + options_list + [source]
return cmd_list
def get_encoding():
"""Called by utils.convert_item().
Based on the get_encoding() function in youtube-dl-gui.
Returns:
The system encoding.
"""
try:
encoding = locale.getpreferredencoding()
'TEST'.encode(encoding)
except:
encoding = 'UTF-8'
return encoding
def get_options_manager(app_obj, media_data_obj):
"""Can be called by anything. Subsequently 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 is_youtube(url):
"""Can be called by anything.
Checks whether a link is a YouTube link or not.
Args:
url (str): The weblink to check
Returns:
True if it's a YouTube link, False if not
"""
if re.search(r'^https?:\/\/(www)+\.youtube\.com', url):
return True
else:
return False
def open_file(uri):
"""Can be called by anything.
Opens a file using the system's default software (e.g. open a media file in
the default media player; open a weblink in the default browser).
Args:
uri (str): The URI to open
"""
if sys.platform == "win32":
os.startfile(uri)
else:
opener ="open" if sys.platform == "darwin" else "xdg-open"
subprocess.call([opener, uri])
def parse_ytdl_options(options_string):
"""Called by options.OptionsParser.parse() or info.InfoManager.run().
Parses the 'extra_cmd_string' option, which can contain arguments inside
double quotes "..." (arguments that can therefore contain whitespace)
Args:
options_string (str): A string containing various youtube-dl
download options, as described above
Returns:
A separated list of youtube-dl download options
"""
# Set a flag for an item beginning with double quotes, and reset it for an
# item ending in double quotes
quote_flag = False
# Temporary list to hold such quoted arguments
quote_list = []
# Add options, one at a time, to a list
return_list = []
return_string = ''
for item in options_string.split():
quote_flag = (quote_flag or item[0] == "\"")
if quote_flag:
quote_list.append(item)
else:
return_list.append(item)
if quote_flag and item[-1] == "\"":
# Special case mode is over
return_list.append(" ".join(quote_list)[1:-1])
quote_flag = False
quote_list = []
return return_list
def shorten_string(string, num_chars):
"""Can be called by anything.
If string is longer than num_chars, truncates it and adds an ellipsis.
Args:
string (string): The string to convert
num_chars (int): The maximum length of the desired string
Returns:
The converted string
"""
if string and len(string) > num_chars:
num_chars -= 3
string = string[:num_chars] + '...'
return string
def strip_whitespace(string):
"""Can be called by anything.
Removes any leading/trailing whitespace from a string.
Args:
string (str): The string to convert
Returns:
The converted string
"""
if string:
string = re.sub(r'^\s+', '', string)
string = re.sub(r'\s+$', '', string)
return string
def tidy_up_container_name(string, max_length):
"""Called by mainapp.TartubeApp.on_menu_add_channel(),
.on_menu_add_playlist() and .on_menu_add_folder().
Before creating a channel, playlist or folder, tidies up the name.
Removes any leading/trailing whitespace. Reduces multiple whitespace
characters to a single space character. Applies a maximum length.
Also replaces any forward/backward slashes with hyphens (if the user
specifies a name like 'Foo / Bar', that would create a directory on the
filesystem called .../Foo/Bar, which is definitely not what we want).
Args:
string (str): The string to convert
max_length (int): The maximum length of the converted string (should be
mainapp.TartubeApp.container_name_max_len)
Returns:
The converted string
"""
if string:
string = re.sub(r'^\s+', '', string)
string = re.sub(r'\s+$', '', string)
string = re.sub(r'\s+', ' ', string)
string = re.sub(r'[\/\\]', '-', string)
return string[0:max_length]
else:
# Empty string
return string
def tidy_up_long_descrip(string, max_length=80):
"""Can be called by anything.
A modified version of utils.tidy_up_long_string. In this case, the
specified string can contain any number of newline characters. We begin
by splitting that string into a list of lines.
Then we split any line which is longer than the specified maximum length,
which gives us a (possibly longer) list of lines.
Finally we recombine those lines into a single string, with lines joined by
newline characters.
Args:
string (str): The string to convert
max_length (int): The maximum length of lines, before they are
recombined into a single string
Returns:
The converted string
"""
if string:
line_list = []
for line in string.split('\n'):
if line == '':
# Preserve empty lines
line_list.append('')
else:
new_list = textwrap.wrap(
line,
width=max_length,
# Don't split up URLs
break_long_words=False,
break_on_hyphens=False,
)
for mini_line in new_list:
line_list.append(mini_line)
return '\n'.join(line_list)
else:
# Empty string
return string
def tidy_up_long_string(string, max_length=80, reduce_flag=True,
split_words_flag=False):
"""Can be called by anything.
The specified string can contain any number of newline characters.
Replaces newline characters with a single space character.
Optionally reduces multiple whitespace characters and removes initial/
final whitespace character(s).
Then splits the string into a list of lines, each with the specified
maximum length.
Finally recombines those lines into a single string, with lines joined by
newline characters.
Args:
string (str): The string to convert
max_length (int): The maximum length of lines, before they are
recombined into a single string
reduce_flag (bool): If True, initial and final whitespace is removed,
and multiple successive whitespace characters are reduced to a
single space character
split_words_flag(bool): If True, the function will break words
(including hyphenated words) into smaller pieces, if necessary
Returns:
The converted string
"""
if string:
string = re.sub(r'\r\n', ' ', string)
if reduce_flag:
string = re.sub(r'^\s+', '', string)
string = re.sub(r'\s+$', '', string)
string = re.sub(r'\s+', ' ', string)
line_list = []
for line in string.split('\n'):
if line == '':
# Preserve empty lines
line_list.append('')
else:
new_list = textwrap.wrap(
line,
width=max_length,
# Don't split up URLs by default
break_long_words=split_words_flag,
break_on_hyphens=split_words_flag,
)
for mini_line in new_list:
line_list.append(mini_line)
return '\n'.join(line_list)
else:
# Empty string
return string
def to_string(data):
"""Can be called by anything.
Convert any data type to a string.
Args:
data (-): The data type
Returns:
The converted string
"""
return '%s' % data
def upper_case_first(string):
"""Can be called by anything.
Args:
string (str): The string to capitalise
Returns:
The converted string
"""
return string[0].upper() + string[1:]