diff --git a/bin/mpdevil b/bin/mpdevil index 00d4625..8965328 100755 --- a/bin/mpdevil +++ b/bin/mpdevil @@ -26,6 +26,7 @@ import requests from bs4 import BeautifulSoup import threading import datetime +import collections import os import sys import re @@ -372,8 +373,7 @@ class MPRISInterface: # TODO emit Seeked if needed Translate metadata returned by MPD to the MPRIS v2 syntax. http://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata """ - mpd_meta=self._client.currentsong() # raw values needed for cover - song=ClientHelper.song_to_list_dict(mpd_meta) + song=self._client.currentsong() self._metadata={} for tag, xesam_tag in (("album","album"),("title","title"),("date","contentCreated")): if tag in song: @@ -385,17 +385,17 @@ class MPRISInterface: # TODO emit Seeked if needed if tag in song: self._metadata[f"xesam:{xesam_tag}"]=GLib.Variant("as", song[tag]) if "id" in song: - self._metadata["mpris:trackid"]=GLib.Variant("o", f"{self._MPRIS_PATH}/Track/{song['id'][0]}") + self._metadata["mpris:trackid"]=GLib.Variant("o", f"{self._MPRIS_PATH}/Track/{song['id']}") if "duration" in song: - self._metadata["mpris:length"]=GLib.Variant("x", float(song["duration"][0])*1000000) + self._metadata["mpris:length"]=GLib.Variant("x", float(song["duration"])*1000000) if "file" in song: - song_file=song["file"][0] + song_file=song["file"] if "://" in song_file: # remote file self._metadata["xesam:url"]=GLib.Variant("s", song_file) else: lib_path=self._settings.get_value("paths")[self._settings.get_int("active-profile")] self._metadata["xesam:url"]=GLib.Variant("s", f"file://{os.path.join(lib_path, song_file)}") - cover_path=self._client.get_cover_path(mpd_meta) + cover_path=self._client.get_cover_path(song) if cover_path is not None: self._metadata["mpris:artUrl"]=GLib.Variant("s", f"file://{cover_path}") @@ -441,9 +441,9 @@ class MPRISInterface: # TODO emit Seeked if needed # MPD client wrapper # ###################### -class ClientHelper(): - def seconds_to_display_time(seconds): - delta=datetime.timedelta(seconds=seconds) +class Duration(float): + def __str__(self): + delta=datetime.timedelta(seconds=int(self)) if delta.days > 0: days=ngettext("{days} day", "{days} days", delta.days).format(days=delta.days) time_string=f"{days}, {datetime.timedelta(seconds=delta.seconds)}" @@ -451,9 +451,24 @@ class ClientHelper(): time_string=str(delta).lstrip("0").lstrip(":") return time_string.replace(":", "∶") # use 'ratio' as delimiter - def convert_audio_format(audio_format): +class LastModified(): + def __init__(self, date): + self._date=date + + def __str__(self): + time=datetime.datetime.strptime(self._date, "%Y-%m-%dT%H:%M:%SZ") + return time.strftime("%a %d %B %Y, %H∶%M UTC") + + def raw(self): + return self._date + +class Format(): + def __init__(self, audio_format): + self._format=audio_format + + def __str__(self): # see: https://www.musicpd.org/doc/html/user.html#audio-output-format - samplerate, bits, channels=audio_format.split(":") + samplerate, bits, channels=self._format.split(":") if bits == "f": bits="32fp" try: @@ -467,52 +482,40 @@ class ClientHelper(): channels=ngettext("{channels} channel", "{channels} channels", int_chan).format(channels=int_chan) return f"{freq} kHz • {bits} bit • {channels}" - def song_to_str_dict(song): # converts tags with multiple values to comma separated strings - return_song={} - for tag, value in song.items(): - if isinstance(value, list): - return_song[tag]=(", ".join(value)) + def raw(self): + return self._format + +class MultiTag(list): + def __str__(self): + return ", ".join(self) + +class Song(collections.UserDict): + def __setitem__(self, key, value): + # time is deprecated https://mpd.readthedocs.io/en/latest/protocol.html#other-metadata + if key != "time": + if key == "duration": + super().__setitem__(key, Duration(value)) + elif key == "format": + super().__setitem__(key, Format(value)) + elif key == "last-modified": + super().__setitem__(key, LastModified(value)) + elif key in ("range", "file", "pos", "id"): + super().__setitem__(key, value) else: - return_song[tag]=value - return return_song + if isinstance(value, list): + super().__setitem__(key, MultiTag(value)) + else: + super().__setitem__(key, MultiTag([value])) - def song_to_first_str_dict(song): # extracts the first value of multiple value tags - return_song={} - for tag, value in song.items(): - if isinstance(value, list): - return_song[tag]=value[0] - else: - return_song[tag]=value - return return_song - - def song_to_list_dict(song): # converts all values to lists - return_song={} - for tag, value in song.items(): - if isinstance(value, list): - return_song[tag]=value - else: - return_song[tag]=[value] - return return_song - - def pepare_song_for_display(song): - base_song={ - "title": os.path.basename(song["file"]), - "track": "", - "disc": "", - "artist": "", - "album": "", - "duration": "0.0", - "date": "", - "genre": "" - } - base_song.update(song) - base_song["human_duration"]=ClientHelper.seconds_to_display_time(int(float(base_song["duration"]))) - for tag in ("disc", "track"): # remove confusing multiple tags - if tag in song: - if isinstance(song[tag], list): - base_song[tag]=song[tag][0] - return base_song + def __missing__(self, key): + if key == "title": + return MultiTag([os.path.basename(self.data["file"])]) + elif key == "duration": + return "0" + else: + return MultiTag([""]) +class ClientHelper(): def binary_to_pixbuf(binary, size): loader=GdkPixbuf.PixbufLoader() try: @@ -570,6 +573,18 @@ class Client(MPDClient): # connect self._settings.connect("changed::active-profile", self._on_active_profile_changed) + # overloads + def currentsong(self, *args): + return Song(super().currentsong(*args)) + def search(self, *args): + return [Song(song) for song in super().search(*args)] + def find(self, *args): + return [Song(song) for song in super().find(*args)] + def playlistinfo(self): + return [Song(song) for song in super().playlistinfo()] + def plchanges(self, version): + return [Song(song) for song in super().plchanges(version)] + def start(self): self.emitter.emit("disconnected") # bring player in defined state active=self._settings.get_int("active-profile") @@ -704,10 +719,9 @@ class Client(MPDClient): else: return([]) - def get_cover_path(self, raw_song): + def get_cover_path(self, song): path=None - song=ClientHelper.song_to_first_str_dict(raw_song) - song_file=song.get("file") + song_file=song["file"] active_profile=self._settings.get_int("active-profile") lib_path=self._settings.get_lib_path() if lib_path is not None: @@ -715,14 +729,14 @@ class Client(MPDClient): if regex_str == "": regex=re.compile(COVER_REGEX, flags=re.IGNORECASE) else: - regex_str=regex_str.replace("%AlbumArtist%", song.get("albumartist", "")) - regex_str=regex_str.replace("%Album%", song.get("album", "")) + regex_str=regex_str.replace("%AlbumArtist%", song["albumartist"][0]) + regex_str=regex_str.replace("%Album%", song["album"][0]) try: regex=re.compile(regex_str, flags=re.IGNORECASE) except: print("illegal regex:", regex_str) return (None, None) - if song_file is not None: + if song_file: song_dir=os.path.join(lib_path, os.path.dirname(song_file)) if song_dir.endswith(".cue"): song_dir=os.path.dirname(song_dir) # get actual directory of .cue file @@ -758,7 +772,7 @@ class Client(MPDClient): pixbuf=ClientHelper.file_to_pixbuf(cover_path, size) return pixbuf - def get_metadata(self, uri): + def get_metadata(self, uri): # TODO Song meta_base=self.lsinfo(uri)[0] try: # .cue files produce an error here meta_extra=self.readcomments(uri) # contains comment tag @@ -791,19 +805,20 @@ class Client(MPDClient): years=self.comp_list("date", "album", album, artist_type, artist, *genre_filter) for year in years: count=self.count(artist_type, artist, "album", album, "date", year, *genre_filter) + duration=Duration(count["playtime"]) song=self.find("album", album, "date", year, artist_type, artist, *genre_filter, "window", "0:1")[0] cover_path=self.get_cover_path(song) if cover_path is None: - cover_binary=self.get_cover_binary(song.get("file")) + cover_binary=self.get_cover_binary(song["file"]) if cover_binary is None: albums.append({"artist": artist,"album": album,"year": year, - "length": count["songs"],"duration": count["playtime"]}) + "length": count["songs"],"duration": duration}) else: albums.append({"artist": artist,"album": album,"year": year, - "length": count["songs"],"duration": count["playtime"],"cover_binary": cover_binary}) + "length": count["songs"],"duration": duration,"cover_binary": cover_binary}) else: albums.append({"artist": artist,"album": album,"year": year, - "length": count["songs"],"duration": count["playtime"], "cover_path": cover_path}) + "length": count["songs"],"duration": duration, "cover_path": cover_path}) self.tagtypes("all") return albums @@ -1371,7 +1386,7 @@ class ServerStats(Gtk.Dialog): stats=client.stats() stats["protocol"]=str(client.mpd_version) for key in ("uptime","playtime","db_playtime"): - stats[key]=ClientHelper.seconds_to_display_time(int(stats[key])) + stats[key]=str(Duration(stats[key])) stats["db_update"]=str(datetime.datetime.fromtimestamp(int(stats["db_update"]))).replace(":", "∶") for i, key in enumerate(("protocol","uptime","playtime","db_update","db_playtime","artists","albums","songs")): @@ -1573,18 +1588,14 @@ class SongPopover(Gtk.Popover): window=self.get_toplevel() self._scroll.set_max_content_height(window.get_size()[1]//2) self._store.clear() - song=ClientHelper.song_to_str_dict(self._client.get_metadata(uri)) - song.pop("time", None) + song=Song(self._client.get_metadata(uri)) # TODO Song for tag, value in song.items(): if tag == "duration": - self._store.append([tag+":", ClientHelper.seconds_to_display_time(int(float(value))), locale.str(float(value))]) - elif tag == "last-modified": - time=datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") - self._store.append([tag+":", time.strftime("%a %d %B %Y, %H∶%M UTC"), value]) - elif tag == "format": - self._store.append([tag+":", ClientHelper.convert_audio_format(value), value]) + self._store.append([tag+":", str(value), locale.str(value)]) + elif tag in ("last-modified", "format"): + self._store.append([tag+":", str(value), value.raw()]) else: - self._store.append([tag+":", value, GLib.markup_escape_text(value)]) + self._store.append([tag+":", str(value), GLib.markup_escape_text(str(value))]) abs_path=self._client.get_absolute_path(uri) if abs_path is None: # show open with button when song is on the same computer self._open_button_revealer.set_reveal_child(False) @@ -1785,8 +1796,7 @@ class AlbumPopover(Gtk.Popover): self._client.restrict_tagtypes("track", "title", "artist") songs=self._client.find("album", album, "date", date, self._settings.get_artist_type(), album_artist, *genre_filter) self._client.tagtypes("all") - for s in songs: - song=ClientHelper.song_to_list_dict(ClientHelper.pepare_song_for_display(s)) + for song in songs: track=song["track"][0] title=(", ".join(song["title"])) # only show artists =/= albumartist @@ -1794,12 +1804,12 @@ class AlbumPopover(Gtk.Popover): song["artist"].remove(album_artist) except: pass - artist=(", ".join(song["artist"])) + artist=str(song["artist"]) if artist == album_artist or artist == "": title_artist=f"{GLib.markup_escape_text(title)}" else: title_artist=f"{GLib.markup_escape_text(title)} • {GLib.markup_escape_text(artist)}" - self._store.append([track, title_artist, song["human_duration"][0], song["file"][0], title]) + self._store.append([track, title_artist, str(song["duration"]), song["file"], title]) self._songs_view.set_model(self._store) self.popup() self._songs_view.columns_autosize() @@ -1990,19 +2000,18 @@ class SearchWindow(Gtk.Box): stripe_start=stripe_size while songs: hits+=len(songs) - for s in songs: + for song in songs: if self._stop_flag: self._done_callback() return - song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) try: - int_track=int(song["track"]) + int_track=int(song["track"][0]) except: int_track=0 self._store.insert_with_valuesv(-1, range(7), [ - song["track"], song["title"], - song["artist"], song["album"], - song["human_duration"], song["file"], + str(song["track"]), str(song["title"]), + str(song["artist"]), str(song["album"]), + str(song["duration"]), song["file"], int_track ]) self.search_entry.progress_pulse() @@ -2193,11 +2202,11 @@ class ArtistWindow(SelectionList): if genre is not None: self.select_all() else: - song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) - if song != {}: - artist=song.get(self._settings.get_artist_type()) - if artist is None: - artist=song.get("artist", "") + song=self._client.currentsong() + if song: + artist=song[self._settings.get_artist_type()][0] + if not artist: + artist=song["artist"][0] self.select(artist) else: if self.length() > 0: @@ -2315,8 +2324,8 @@ class AlbumWindow(FocusFrame): def scroll_to_current_album(self): def callback(): - song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) - album=song.get("album", "") + song=self._client.currentsong() + album=song["album"][0] self._iconview.unselect_all() row_num=len(self._store) for i in range(0, row_num): @@ -2381,7 +2390,7 @@ class AlbumWindow(FocusFrame): fallback_cover=GdkPixbuf.Pixbuf.new_from_file_at_size(FALLBACK_COVER, size, size) for i, album in enumerate(albums): # tooltip - duration=ClientHelper.seconds_to_display_time(int(album["duration"])) + duration=str(album["duration"]) length=int(album["length"]) tooltip=ngettext("{number} song ({duration})", "{number} songs ({duration})", length).format( number=length, duration=duration) @@ -2564,16 +2573,16 @@ class Browser(Gtk.Paned): self.pack2(self._albums_search_stack, True, False) def _back_to_current_album(self, force=False): - song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) - if song != {}: + song=self._client.currentsong() + if song: self.search_button.set_active(False) self._genres_button.set_active(False) # get artist name - artist=song.get(self._settings.get_artist_type()) - if artist is None: - artist=song.get("artist", "") + artist=song[self._settings.get_artist_type()][0] + if not artist: + artist=song["artist"][0] # deactivate genre filter to show all artists (if needed) - if song.get("genre", "") != self._genre_select.get_selected() or force: + if song["genre"][0] != self._genre_select.get_selected() or force: self._genre_select.deactivate() # select artist if self._artist_window.get_selected() is None and not force: # all artists selected @@ -2717,7 +2726,7 @@ class LyricsWindow(FocusFrame): def _display_lyrics(self, current_song): GLib.idle_add(self._text_buffer.set_text, _("searching…"), -1) try: - text=self._get_lyrics(current_song.get("title", ""), current_song.get("artist", "")) + text=self._get_lyrics(current_song["title"][0], current_song["artist"][0]) except requests.exceptions.ConnectionError: self._displayed_song_file=None text=_("connection error") @@ -2727,17 +2736,17 @@ class LyricsWindow(FocusFrame): def _refresh(self, *args): current_song=self._client.currentsong() - if current_song == {}: - self._displayed_song_file=None - self._text_buffer.set_text("", -1) - else: + if current_song: self._displayed_song_file=current_song["file"] update_thread=threading.Thread( target=self._display_lyrics, - kwargs={"current_song": ClientHelper.song_to_first_str_dict(current_song)}, + kwargs={"current_song": current_song}, daemon=True ) update_thread.start() + else: + self._displayed_song_file=None + self._text_buffer.set_text("", -1) def _on_disconnected(self, *args): self._displayed_song_file=None @@ -2763,14 +2772,13 @@ class CoverEventBox(Gtk.EventBox): window.begin_move_drag(1, event.x_root, event.y_root, Gdk.CURRENT_TIME) else: if self._client.connected(): - song=ClientHelper.song_to_first_str_dict(self._client.currentsong()) - if song != {}: - try: - artist=song[self._settings.get_artist_type()] - except: - artist=song.get("artist", "") - album=song.get("album", "") - year=song.get("date", "") + song=self._client.currentsong() + if song: + artist=song[self._settings.get_artist_type()][0] + if not artist: + artist=song["artist"][0] + album=song["album"][0] + year=song["date"][0] if event.button == 1 and event.type == Gdk.EventType.BUTTON_PRESS: self._client.album_to_playlist(album, artist, year, None) elif event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: @@ -3031,32 +3039,31 @@ class PlaylistWindow(Gtk.Overlay): else: songs=self._client.playlistinfo() self._client.tagtypes("all") - if songs != []: + if songs: self._treeview.freeze_child_notify() self._set_playlist_info("") - for s in songs: - song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(s)) + for song in songs: try: treeiter=self._store.get_iter(song["pos"]) self._store.set(treeiter, - 0, song["track"], - 1, song["disc"], - 2, song["title"], - 3, song["artist"], - 4, song["album"], - 5, song["human_duration"], - 6, song["date"], - 7, song["genre"], + 0, str(song["track"]), + 1, str(song["disc"]), + 2, str(song["title"]), + 3, str(song["artist"]), + 4, str(song["album"]), + 5, str(song["duration"]), + 6, str(song["date"]), + 7, str(song["genre"]), 8, song["file"], 9, Pango.Weight.BOOK, 10, float(song["duration"]) ) except: self._store.insert_with_valuesv(-1, range(11), [ - song["track"], song["disc"], - song["title"], song["artist"], - song["album"], song["human_duration"], - song["date"], song["genre"], + str(song["track"]), str(song["disc"]), + str(song["title"]), str(song["artist"]), + str(song["album"]), str(song["duration"]), + str(song["date"]), str(song["genre"]), song["file"], Pango.Weight.BOOK, float(song["duration"]) ]) @@ -3068,9 +3075,9 @@ class PlaylistWindow(Gtk.Overlay): if playlist_length == 0: self._set_playlist_info("") else: - duration_human_readable=ClientHelper.seconds_to_display_time(int(sum([row[10] for row in self._store]))) + duration=Duration(sum([row[10] for row in self._store])) translated_string=ngettext("{number} song ({duration})", "{number} songs ({duration})", playlist_length) - self._set_playlist_info(translated_string.format(number=playlist_length, duration=duration_human_readable)) + self._set_playlist_info(translated_string.format(number=playlist_length, duration=duration)) self._refresh_selection() if self._playlist_version != version: self._scroll_to_selected_title() @@ -3312,12 +3319,12 @@ class SeekBar(Gtk.Box): self._adjustment.set_upper(duration) if self._update: self._scale.set_value(elapsed) - self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed))) - self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed))) + self._elapsed.set_text(str(Duration(elapsed))) + self._rest.set_text(f"-{Duration(duration-elapsed)}") self._scale.set_fill_level(elapsed) else: self._disable() - self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed))) + self._elapsed.set_text(str(Duration(elapsed))) def _disable(self, *args): self.set_sensitive(False) @@ -3354,8 +3361,8 @@ class SeekBar(Gtk.Box): elapsed=duration else: elapsed=value - self._elapsed.set_text(ClientHelper.seconds_to_display_time(int(elapsed))) - self._rest.set_text("-"+ClientHelper.seconds_to_display_time(int(duration-elapsed))) + self._elapsed.set_text(str(Duration(elapsed))) + self._rest.set_text(f"-{Duration(duration-elapsed)}") self._jumped=True def _on_elapsed_button_release_event(self, widget, event): @@ -3410,7 +3417,7 @@ class AudioFormat(Gtk.Box): if audio_format is None: self._format_label.set_markup(" ") else: - self._format_label.set_markup(f"{ClientHelper.convert_audio_format(audio_format)}") + self._format_label.set_markup(f"{Format(audio_format)}") def _on_bitrate(self, emitter, brate): # handle unknown bitrates: https://github.com/MusicPlayerDaemon/MPD/issues/428#issuecomment-442430365 @@ -3987,29 +3994,28 @@ class MainWindow(Gtk.ApplicationWindow): def _on_song_changed(self, *args): song=self._client.currentsong() - if song == {}: - self.set_title("mpdevil") - if self._use_csd: - self._header_bar.set_subtitle("") - else: - song=ClientHelper.song_to_str_dict(ClientHelper.pepare_song_for_display(song)) - if song["date"] == "": - date="" - else: + if song: + if "date" in song: date=f"({song['date']})" - album_with_date=" ".join(filter(None, (song["album"], date))) + else: + date="" + album_with_date=" ".join(filter(None, (str(song["album"]), date))) if self._use_csd: - self.set_title(" • ".join(filter(None, (song["title"], song["artist"])))) + self.set_title(" • ".join(filter(None, (str(song["title"]), str(song["artist"]))))) self._header_bar.set_subtitle(album_with_date) else: - self.set_title(" • ".join(filter(None, (song["title"], song["artist"], album_with_date)))) + self.set_title(" • ".join(filter(None, (str(song["title"]), str(song["artist"]), album_with_date)))) if self._settings.get_boolean("send-notify"): if not self.is_active() and self._client.status()["state"] == "play": self._notify.close() # clear previous notifications - self._notify.update(song["title"], f"{song['artist']}\n{song['album']}{date}") + self._notify.update(str(song["title"]), f"{song['artist']}\n{song['album']}{date}") pixbuf=self._client.get_cover(song, 400) self._notify.set_image_from_pixbuf(pixbuf) self._notify.show() + else: + self.set_title("mpdevil") + if self._use_csd: + self._header_bar.set_subtitle("") def _on_reconnected(self, *args): for action in ("stats","toggle-lyrics","back-to-current-album","toggle-search"):