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