1# Copyright (c) 2014-2021 Cedric Bellegarde <cedric.bellegarde@adishatz.org>
2# This program is free software: you can redistribute it and/or modify
3# it under the terms of the GNU General Public License as published by
4# the Free Software Foundation, either version 3 of the License, or
5# (at your option) any later version.
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU General Public License for more details.
10# You should have received a copy of the GNU General Public License
11# along with this program. If not, see <http://www.gnu.org/licenses/>.
12
13from gi.repository import Gio, GLib, Gdk, GdkPixbuf, Pango, Gtk
14
15from math import pi
16from gettext import gettext as _
17from urllib.parse import urlparse
18import unicodedata
19import cairo
20import time
21import re
22from hashlib import md5
23from threading import current_thread
24from functools import wraps
25
26from lollypop.logger import Logger
27from lollypop.define import App, Type, NetworkAccessACL
28from lollypop.define import StorageType
29from lollypop.shown import ShownLists
30
31
32def make_subrequest(value, operand, count):
33    """
34        Make a subrequest for value and operand
35        @param value as str   => SQL
36        @param operand as str => OR/AND
37        @param count as int => iteration count
38    """
39    subrequest = "("
40    while count != 0:
41        if subrequest != "(":
42            subrequest += " %s " % operand
43        subrequest += value
44        count -= 1
45    return subrequest + ")"
46
47
48def ms_to_string(duration):
49    """
50        Convert milliseconds to a pretty string
51        @param duration as int
52    """
53    hours = duration // 3600000
54    if hours == 0:
55        minutes = duration // 60000
56        seconds = (duration % 60000) // 1000
57        return "%i:%02i" % (minutes, seconds)
58    else:
59        seconds = duration % 3600000
60        minutes = seconds // 60000
61        seconds = (duration % 60000) // 1000
62        return "%i:%02i:%02i" % (hours, minutes, seconds)
63
64
65def get_human_duration(duration):
66    """
67        Get human readable duration
68        @param duration in milliseconds
69        @return str
70    """
71    hours = duration // 3600000
72    minutes = duration // 60000
73    if hours > 0:
74        seconds = duration % 3600000
75        minutes = seconds // 60000
76        if minutes > 0:
77            return _("%s h  %s m") % (hours, minutes)
78        else:
79            return _("%s h") % hours
80    else:
81        return _("%s m") % minutes
82
83
84def get_round_surface(surface, scale_factor, radius):
85    """
86        Get rounded surface from surface/pixbuf
87        @param surface as GdkPixbuf.Pixbuf/cairo.Surface
88        @return surface as cairo.Surface
89        @param scale_factor as int
90        @param radius as int
91        @warning not thread safe!
92    """
93    width = surface.get_width()
94    height = surface.get_height()
95    if isinstance(surface, GdkPixbuf.Pixbuf):
96        pixbuf = surface
97        width = width // scale_factor
98        height = height // scale_factor
99        radius = radius // scale_factor
100        surface = Gdk.cairo_surface_create_from_pixbuf(
101            pixbuf, scale_factor, None)
102    rounded = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
103    ctx = cairo.Context(rounded)
104    degrees = pi / 180
105    ctx.arc(width - radius, radius, radius, -90 * degrees, 0 * degrees)
106    ctx.arc(width - radius, height - radius,
107            radius, 0 * degrees, 90 * degrees)
108    ctx.arc(radius, height - radius, radius, 90 * degrees, 180 * degrees)
109    ctx.arc(radius, radius, radius, 180 * degrees, 270 * degrees)
110    ctx.close_path()
111    ctx.set_line_width(10)
112    ctx.set_source_surface(surface, 0, 0)
113    ctx.clip()
114    ctx.paint()
115    return rounded
116
117
118def set_cursor_type(widget, name="pointer"):
119    """
120        Set cursor on widget
121        @param widget as Gtk.Widget
122        @param name as str
123    """
124    try:
125        window = widget.get_window()
126        if window is not None:
127            cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(),
128                                              name)
129            window.set_cursor(cursor)
130    except:
131        pass
132
133
134def get_default_storage_type():
135    """
136        Get default collection storage type check
137    """
138    if get_network_available("YOUTUBE"):
139        return StorageType.COLLECTION | StorageType.SAVED
140    else:
141        return StorageType.COLLECTION
142
143
144def on_query_tooltip(label, x, y, keyboard, tooltip):
145    """
146        Show label tooltip if needed
147        @param label as Gtk.Label
148        @param x as int
149        @param y as int
150        @param keyboard as bool
151        @param tooltip as Gtk.Tooltip
152    """
153    layout = label.get_layout()
154    if layout.is_ellipsized():
155        tooltip.set_markup(label.get_label())
156        return True
157
158
159def init_proxy_from_gnome():
160    """
161        Set proxy settings from GNOME
162        @return (host, port) as (str, int) or (None, None)
163    """
164    try:
165        proxy = Gio.Settings.new("org.gnome.system.proxy")
166        mode = proxy.get_value("mode").get_string()
167        if mode == "manual":
168            for name in ["org.gnome.system.proxy.http",
169                         "org.gnome.system.proxy.https"]:
170                setting = Gio.Settings.new(name)
171                host = setting.get_value("host").get_string()
172                port = setting.get_value("port").get_int32()
173                if host != "" and port != 0:
174                    return (host, port)
175
176            # Try with a socks proxy
177            # returning host, port not needed as PySocks will override values
178            socks = Gio.Settings.new("org.gnome.system.proxy.socks")
179            host = socks.get_value("host").get_string()
180            port = socks.get_value("port").get_int32()
181            proxy = "socks4://%s:%s" % (host, port)
182            from os import environ
183            environ["all_proxy"] = proxy
184            environ["ALL_PROXY"] = proxy
185            if host != "" and port != 0:
186                import socket
187                import socks
188                socks.set_default_proxy(socks.SOCKS4, host, port)
189                socket.socket = socks.socksocket
190    except Exception as e:
191        Logger.warning("set_proxy_from_gnome(): %s", e)
192    return (None, None)
193
194
195def debug(str):
196    """
197        Print debug
198        @param str as str
199    """
200    if App().debug is True:
201        print(str)
202
203
204def get_network_available(acl_name=""):
205    """
206        Return True if network available
207        @param acl_name as str
208        @return bool
209    """
210    if not App().settings.get_value("network-access"):
211        return False
212    elif acl_name == "":
213        return Gio.NetworkMonitor.get_default().get_network_available()
214    else:
215        acl = App().settings.get_value("network-access-acl").get_int32()
216        if acl & NetworkAccessACL[acl_name]:
217            return Gio.NetworkMonitor.get_default().get_network_available()
218    return False
219
220
221def noaccents(string):
222    """
223        Return string without accents lowered
224        @param string as str
225        @return str
226    """
227    nfkd_form = unicodedata.normalize("NFKD", string)
228    v = u"".join([c for c in nfkd_form if not unicodedata.combining(c)])
229    return v.lower()
230
231
232def sql_escape(string):
233    """
234        Escape string for SQL request
235        @param string as str
236        @param ignore as [str]
237    """
238    nfkd_form = unicodedata.normalize("NFKD", string)
239    v = u"".join([c for c in nfkd_form if not unicodedata.combining(c)])
240    return "".join([c for c in v if
241                    c.isalpha() or
242                    c.isdigit()]).rstrip().lower()
243
244
245def escape(str, ignore=["_", "-", " ", "."]):
246    """
247        Escape string
248        @param str as str
249        @param ignore as [str]
250    """
251    return "".join([c for c in str if
252                    c.isalpha() or
253                    c.isdigit() or c in ignore]).rstrip()
254
255
256def get_lollypop_album_id(name, artists, year=None):
257    """
258        Calculate Lollypop album id
259        @param name as str
260        @param artists as [str]
261        @param year as int/None
262    """
263    if year is None:
264        name = "%s_%s" % (sql_escape(" ".join(artists)), sql_escape(name))
265    else:
266        name = "%s_%s_%s" % (
267            sql_escape(" ".join(artists)), sql_escape(name), year)
268    return md5(name.encode("utf-8")).hexdigest()
269
270
271def get_lollypop_track_id(name, artists, album_name):
272    """
273        Calculate Lollypop track id
274        @param name as str
275        @param artists as [str]
276        @param year as str
277        @param album_name as str
278    """
279    name = "%s_%s_%s" % (sql_escape(" ".join(artists)), sql_escape(name),
280                         sql_escape(album_name))
281    return md5(name.encode("utf-8")).hexdigest()
282
283
284def get_iso_date_from_string(string):
285    """
286        Convert any string to an iso date
287        @param string as str
288        @return str/None
289    """
290    model = ["1970", "01", "01", "00", "00", "00"]
291    try:
292        split = re.split('[-:TZ]', string)
293        length = len(split)
294        while length < 6:
295            split.append(model[length])
296            length = len(split)
297        return "%s-%s-%sT%s:%s:%sZ" % (split[0], split[1], split[2],
298                                       split[3], split[4], split[5])
299    except Exception as e:
300        Logger.error("get_iso_date_from_string(): %s -> %s", string, e)
301        return None
302
303
304def format_artist_name(name):
305    """
306        Return formated artist name
307        @param name as str
308    """
309    if not App().settings.get_value("smart-artist-sort"):
310        return name
311    # Handle language ordering
312    # Translators: Add here words that shoud be ignored for artist sort order
313    # Translators: Add The the too
314    for special in _("The the").split():
315        if name.startswith(special + " "):
316            strlen = len(special) + 1
317            name = name[strlen:] + ", " + special
318    return name
319
320
321def emit_signal(obj, signal, *args):
322    """
323        Emit signal
324        @param obj as GObject.Object
325        @param signal as str
326        @thread safe
327    """
328    if current_thread().getName() == "MainThread":
329        obj.emit(signal, *args)
330    else:
331        GLib.idle_add(obj.emit, signal, *args)
332
333
334def translate_artist_name(name):
335    """
336        Return translate formated artist name
337        @param name as str
338    """
339    split = name.split("@@@@")
340    if len(split) == 2:
341        name = split[1] + " " + split[0]
342    return name
343
344
345def get_page_score(page_title, title, artist, album):
346    """
347        Calculate web page score
348        if page_title looks like (title, artist, album), score is lower
349        @return int/None
350    """
351    page_title = escape(page_title.lower(), [])
352    artist = escape(artist.lower(), [])
353    album = escape(album.lower(), [])
354    title = escape(title.lower(), [])
355    # YouTube page title should be at least as long as wanted title
356    if len(page_title) < len(title):
357        return -1
358    # Remove common word for a valid track
359    page_title = page_title.replace("official", "")
360    page_title = page_title.replace("video", "")
361    page_title = page_title.replace("audio", "")
362    # Remove artist name
363    page_title = page_title.replace(artist, "")
364    # Remove album name
365    page_title = page_title.replace(album, "")
366    # Remove title
367    page_title = page_title.replace(title, "")
368    return len(page_title)
369
370
371def remove_static(ids):
372    """
373        Remove static ids
374        @param ids as [int]
375        @return [int]
376    """
377    # Special case for Type.WEB, only static item present in DB
378    return [item for item in ids if item >= 0 or item == Type.WEB]
379
380
381def get_font_height():
382    """
383        Get current font height
384        @return int
385    """
386    ctx = App().window.get_pango_context()
387    layout = Pango.Layout.new(ctx)
388    layout.set_text("A", 1)
389    return int(layout.get_pixel_size()[1])
390
391
392def get_icon_name(object_id):
393    """
394        Return icon name for id
395        @param object_id as int
396    """
397    icon = ""
398    if object_id == Type.SUGGESTIONS:
399        icon = "org.gnome.Lollypop-suggestions-symbolic"
400    elif object_id == Type.POPULARS:
401        icon = "starred-symbolic"
402    elif object_id == Type.PLAYLISTS:
403        icon = "emblem-documents-symbolic"
404    elif object_id == Type.ALL:
405        icon = "media-optical-cd-audio-symbolic"
406    elif object_id == Type.ARTISTS:
407        icon = "avatar-default-symbolic"
408    elif object_id == Type.ARTISTS_LIST:
409        icon = "org.gnome.Lollypop-artists-list-symbolic"
410    elif object_id == Type.COMPILATIONS:
411        icon = "system-users-symbolic"
412    elif object_id == Type.RECENTS:
413        icon = "document-open-recent-symbolic"
414    elif object_id == Type.RANDOMS:
415        icon = "media-playlist-shuffle-symbolic"
416    elif object_id == Type.LOVED:
417        icon = "emblem-favorite-symbolic"
418    elif object_id == Type.LITTLE:
419        icon = "org.gnome.Lollypop-unplayed-albums-symbolic"
420    elif object_id == Type.YEARS:
421        icon = "x-office-calendar-symbolic"
422    elif object_id == Type.CURRENT:
423        icon = "org.gnome.Lollypop-play-queue-symbolic"
424    elif object_id == Type.LYRICS:
425        icon = "audio-input-microphone-symbolic"
426    elif object_id == Type.SEARCH:
427        icon = "edit-find-symbolic"
428    elif object_id == Type.GENRES:
429        icon = "org.gnome.Lollypop-tag-symbolic"
430    elif object_id == Type.GENRES_LIST:
431        icon = "org.gnome.Lollypop-tag-list-symbolic"
432    elif object_id == Type.WEB:
433        icon = "goa-panel-symbolic"
434    elif object_id == Type.INFO:
435        icon = "dialog-information-symbolic"
436    return icon
437
438
439def get_title_for_genres_artists(genre_ids, artist_ids):
440    """
441        Return title for genres/artists
442        @param genre_ids as [int]
443        @param artist_ids as [int]
444        @return str
445    """
446    if genre_ids and genre_ids[0] == Type.YEARS and artist_ids:
447        title_str = "%s - %s" % (artist_ids[0], artist_ids[-1])
448    else:
449        genres = []
450        for genre_id in genre_ids:
451            if genre_id < 0:
452                genres.append(ShownLists.IDS[genre_id])
453            else:
454                genre = App().genres.get_name(genre_id)
455                if genre is not None:
456                    genres.append(genre)
457        title_str = ",".join(genres)
458    return title_str
459
460
461def popup_widget(widget, parent, x, y, state_widget):
462    """
463        Popup menu on widget as x, y
464        @param widget as Gtk.Widget
465        @param parent as Gtk.Widget
466        @param x as int
467        @param y as int
468        @param state_widget as Gtk.Widget
469        @return Gtk.Popover/None
470    """
471    def on_hidden(widget, hide, popover):
472        popover.popdown()
473
474    def on_unmap(popover, parent):
475        parent.unset_state_flags(Gtk.StateFlags.VISITED)
476
477    if App().window.folded:
478        App().window.container.show_menu(widget)
479        return None
480    else:
481        from lollypop.widgets_popover import Popover
482        popover = Popover()
483        popover.add(widget)
484        widget.connect("hidden", on_hidden, popover)
485        if state_widget is not None:
486            if not state_widget.get_state_flags() & Gtk.StateFlags.VISITED:
487                popover.connect("unmap", on_unmap, state_widget)
488                state_widget.set_state_flags(Gtk.StateFlags.VISITED, False)
489        popover.set_relative_to(parent)
490        # Workaround a GTK autoscrolling issue in Gtk.ListBox
491        # Gtk autoscroll to last focused widget on popover close
492        if state_widget is not None:
493            state_widget.grab_focus()
494        if x is not None and y is not None:
495            rect = Gdk.Rectangle()
496            rect.x = x
497            rect.y = y
498            rect.width = rect.height = 1
499            popover.set_pointing_to(rect)
500        popover.set_position(Gtk.PositionType.BOTTOM)
501        popover.popup()
502        return popover
503
504
505def is_device(mount):
506    """
507        True if mount is a Lollypop device
508        @param mount as Gio.Mount
509        @return bool
510    """
511    if mount.get_volume() is None:
512        return False
513    uri = mount.get_default_location().get_uri()
514    if uri is None:
515        return False
516    parsed = urlparse(uri)
517    if parsed.scheme == "mtp":
518        return True
519    elif not App().settings.get_value("sync-usb-disks"):
520        return False
521    drive = mount.get_drive()
522    return drive is not None and drive.is_removable()
523
524
525def profile(f):
526    """
527        Decorator to get execution time of a function
528    """
529    @wraps(f)
530    def wrapper(*args, **kwargs):
531        start_time = time.perf_counter()
532
533        ret = f(*args, **kwargs)
534
535        elapsed_time = time.perf_counter() - start_time
536        Logger.info("%s::%s: execution time %d:%f" % (
537            f.__module__, f.__name__, elapsed_time / 60, elapsed_time % 60))
538
539        return ret
540
541    return wrapper
542
543
544def split_list(l, n=1):
545    """
546        Split list in n parts
547        @param l as []
548        @param n as int
549    """
550    length = len(l)
551    split = [l[i * length // n: (i + 1) * length // n] for i in range(n)]
552    return [l for l in split if l]
553