audio: Add an 'artwork' property

And use it to track the existence of artwork for the current file. This
could either be a cover.jpg file in the same directory as the currently
playing track or embedded artwork found in the Gst.TagList.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-07-19 15:51:22 -04:00
parent 7ff1a3d60c
commit ee8db58fb2
7 changed files with 64 additions and 5 deletions

View File

@ -114,7 +114,7 @@ class Application(Adw.Application):
self.mpris.app.connect("Quit", self.win.close) self.mpris.app.connect("Quit", self.win.close)
for tag in ["artist", "album", "album-artist", "album-disc-number", for tag in ["artist", "album", "album-artist", "album-disc-number",
"title", "track-number", "duration", "file"]: "title", "track-number", "duration", "file", "artwork"]:
self.player.bind_property(tag, self.mpris.player, tag) self.player.bind_property(tag, self.mpris.player, tag)
for (prop, mpris_prop) in [("have-track", "CanPlay"), for (prop, mpris_prop) in [("have-track", "CanPlay"),
("have-track", "CanPause"), ("have-track", "CanPause"),

View File

@ -6,6 +6,7 @@ from gi.repository import GLib
from gi.repository import Gst from gi.repository import Gst
from . import replaygain from . import replaygain
from .. import path from .. import path
from .. import tmpdir
UPDATE_INTERVAL = 100 UPDATE_INTERVAL = 100
@ -24,6 +25,7 @@ class Player(GObject.GObject):
position = GObject.Property(type=float, default=0) position = GObject.Property(type=float, default=0)
duration = GObject.Property(type=float, default=0) duration = GObject.Property(type=float, default=0)
volume = GObject.Property(type=float, default=1.0) volume = GObject.Property(type=float, default=1.0)
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
file = GObject.Property(type=GObject.TYPE_PYOBJECT) file = GObject.Property(type=GObject.TYPE_PYOBJECT)
playing = GObject.Property(type=bool, default=False) playing = GObject.Property(type=bool, default=False)
@ -92,8 +94,16 @@ class Player(GObject.GObject):
def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None: def __msg_tags(self, bus: Gst.Bus, message: Gst.Message) -> None:
taglist = message.parse_tag() taglist = message.parse_tag()
for tag in ["artist", "album", "album-artist", "album-disc-number", for tag in ["artist", "album", "album-artist", "album-disc-number",
"title", "track-number"]: "title", "track-number", "artwork"]:
match tag: match tag:
case "artwork":
(res, sample) = taglist.get_sample("image")
if res:
buffer = sample.get_buffer()
(res, map) = buffer.map(Gst.MapFlags.READ)
if res:
value = tmpdir.cover_jpg(map.data)
buffer.unmap(map)
case "track-number" | "album-disc-number": case "track-number" | "album-disc-number":
(res, value) = taglist.get_uint(tag) (res, value) = taglist.get_uint(tag)
case _: case _:
@ -107,11 +117,13 @@ class Player(GObject.GObject):
print(f"audio: loading {uri}") print(f"audio: loading {uri}")
self._playbin.set_property("uri", uri) self._playbin.set_property("uri", uri)
def __reset_properties(self, *, duration: float = 0.0) -> None: def __reset_properties(self, *, duration: float = 0.0,
artwork: pathlib.Path | None = None) -> None:
for tag in ["artist", "album-artist", "album", "title"]: for tag in ["artist", "album-artist", "album", "title"]:
self.set_property(tag, "") self.set_property(tag, "")
for tag in ["album-disc-number", "track-number", "position"]: for tag in ["album-disc-number", "track-number", "position"]:
self.set_property(tag, 0) self.set_property(tag, 0)
self.artwork = artwork
self.duration = duration self.duration = duration
def __update_position(self) -> bool: def __update_position(self) -> bool:
@ -176,7 +188,9 @@ class Player(GObject.GObject):
"""Signal that a new URI has started.""" """Signal that a new URI has started."""
print("audio: file loaded") print("audio: file loaded")
(res, dur) = self._playbin.query_duration(Gst.Format.TIME) (res, dur) = self._playbin.query_duration(Gst.Format.TIME)
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0)) cover = self.file.parent / "cover.jpg"
self.__reset_properties(duration=(dur / Gst.USECOND if res else 0),
artwork=(cover if cover.is_file() else None))
self.have_track = True self.have_track = True
@GObject.Signal @GObject.Signal

View File

@ -38,6 +38,7 @@ class Player(dbus.Object):
trackid = GObject.Property(type=int) trackid = GObject.Property(type=int)
file = GObject.Property(type=GObject.TYPE_PYOBJECT) file = GObject.Property(type=GObject.TYPE_PYOBJECT)
artwork = GObject.Property(type=GObject.TYPE_PYOBJECT)
def __init__(self): def __init__(self):
"""Initialize the mpris2 application object.""" """Initialize the mpris2 application object."""
@ -47,7 +48,8 @@ class Player(dbus.Object):
"""Notify DBus when tags change.""" """Notify DBus when tags change."""
match property: match property:
case "artist" | "album" | "album-artist" | "album-disc-number" | \ case "artist" | "album" | "album-artist" | "album-disc-number" | \
"title" | "track-number" | "duration" | "trackid" | "uri": "title" | "track-number" | "duration" | "trackid" | \
"uri" | "artwork":
changed = GLib.Variant("a{sv}", self.Metadata) changed = GLib.Variant("a{sv}", self.Metadata)
self.properties_changed({"Metadata": changed}) self.properties_changed({"Metadata": changed})
case "PlaybackStatus": case "PlaybackStatus":
@ -73,6 +75,8 @@ class Player(dbus.Object):
res["mpris:length"] = GLib.Variant("x", self.duration) res["mpris:length"] = GLib.Variant("x", self.duration)
res["xesam:url"] = GLib.Variant("s", self.file.as_uri()) res["xesam:url"] = GLib.Variant("s", self.file.as_uri())
if self.artwork is not None:
res["mpris:artUrl"] = GLib.Variant("s", self.artwork.as_uri())
if len(self.artist) > 0: if len(self.artist) > 0:
res["xesam:artist"] = GLib.Variant("as", [self.artist]) res["xesam:artist"] = GLib.Variant("as", [self.artist])
if len(self.album) > 0: if len(self.album) > 0:

View File

@ -1,6 +1,7 @@
# Copyright 2022 (c) Anna Schumaker. # Copyright 2022 (c) Anna Schumaker.
"""Tests our GObject audio player wrapping a GStreamer Playbin element.""" """Tests our GObject audio player wrapping a GStreamer Playbin element."""
import io import io
import pathlib
import unittest import unittest
import unittest.mock import unittest.mock
import emmental.audio import emmental.audio
@ -58,6 +59,7 @@ class TestAudio(unittest.TestCase):
self.player.file = tests.util.TRACK_OGG self.player.file = tests.util.TRACK_OGG
self.player.duration = 10 self.player.duration = 10
self.player.position = 8 self.player.position = 8
self.player.artwork = pathlib.Path("/a/b/c.jpg")
eos = Gst.Message.new_eos(self.player._playbin) eos = Gst.Message.new_eos(self.player._playbin)
self.player._playbin.get_bus().post(eos) self.player._playbin.get_bus().post(eos)
@ -70,6 +72,7 @@ class TestAudio(unittest.TestCase):
for prop in ["album-disc-number", "track-number", for prop in ["album-disc-number", "track-number",
"position", "duration"]: "position", "duration"]:
self.assertEqual(self.player.get_property(prop), 0) self.assertEqual(self.player.get_property(prop), 0)
self.assertIsNone(self.player.artwork)
self.assertEqual(self.player.get_state(), Gst.State.READY) self.assertEqual(self.player.get_state(), Gst.State.READY)
self.assertEqual(self.player.status, "Stopped") self.assertEqual(self.player.status, "Stopped")
@ -254,6 +257,36 @@ class TestAudio(unittest.TestCase):
self.assertRegex(mock_stdout.getvalue(), self.assertRegex(mock_stdout.getvalue(),
r"audio: setting ReplayGain mode to 'disabled'") r"audio: setting ReplayGain mode to 'disabled'")
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_artwork(self, mock_stdout: io.StringIO):
"""Test that we handle album artwork."""
self.assertIsNone(self.player.artwork)
with unittest.mock.patch.object(pathlib.Path, "is_file",
return_value=False):
self.player.file = tests.util.TRACK_OGG
self.player.pause()
self.main_loop()
self.assertIsNone(self.player.artwork)
self.player.stop()
self.player.pause()
self.main_loop()
self.assertEqual(self.player.artwork, tests.util.COVER_JPG)
buffer = Gst.Buffer.new_wrapped_bytes(tests.util.COVER_JPG_BYTES)
taglist = Gst.TagList.new_empty()
taglist.add_value(Gst.TagMergeMode.APPEND, "image",
Gst.Sample.new(buffer))
tag = Gst.Message.new_tag(self.player._playbin, taglist)
self.player._playbin.get_bus().post(tag)
self.main_loop()
expected = emmental.tmpdir.cover_jpg()
self.assertEqual(self.player.artwork,
emmental.tmpdir.cover_jpg())
self.assertTrue(expected.is_file())
def test_shutdown(self): def test_shutdown(self):
"""Test that the shutdown function works as expected.""" """Test that the shutdown function works as expected."""
self.player.shutdown() self.player.shutdown()

View File

@ -4,6 +4,7 @@ import pathlib
import unittest import unittest
import unittest.mock import unittest.mock
import emmental.mpris2.player import emmental.mpris2.player
import tests.util
from gi.repository import GLib from gi.repository import GLib
from gi.repository import Gio from gi.repository import Gio
@ -57,6 +58,7 @@ class TestPlayer(unittest.TestCase):
self.assertEqual(self.player.duration, 0) self.assertEqual(self.player.duration, 0)
self.assertEqual(self.player.trackid, 0) self.assertEqual(self.player.trackid, 0)
self.assertIsNone(self.player.file) self.assertIsNone(self.player.file)
self.assertIsNone(self.player.artwork)
self.assertDictEqual(self.player.Metadata, {}) self.assertDictEqual(self.player.Metadata, {})
@ -66,11 +68,14 @@ class TestPlayer(unittest.TestCase):
self.player.title = "Test Title" self.player.title = "Test Title"
self.player.duration = 12345 self.player.duration = 12345
self.player.file = pathlib.Path("/a/b/c.ogg") self.player.file = pathlib.Path("/a/b/c.ogg")
self.player.artwork = tests.util.COVER_JPG
cover_uri = tests.util.COVER_JPG.as_uri()
res = {"mpris:trackid": res = {"mpris:trackid":
GLib.Variant("o", "/com/nowheycreamery/emmental/0"), GLib.Variant("o", "/com/nowheycreamery/emmental/0"),
"mpris:length": GLib.Variant("x", 12345), "mpris:length": GLib.Variant("x", 12345),
"xesam:url": GLib.Variant("s", "file:///a/b/c.ogg"), "xesam:url": GLib.Variant("s", "file:///a/b/c.ogg"),
"mpris:artUrl": GLib.Variant("s", cover_uri),
"xesam:album": GLib.Variant("s", "Test Album"), "xesam:album": GLib.Variant("s", "Test Album"),
"xesam:albumArtist": GLib.Variant("as", ["Test Album Artist"]), "xesam:albumArtist": GLib.Variant("as", ["Test Album Artist"]),
"xesam:artist": GLib.Variant("as", ["Test Artist"]), "xesam:artist": GLib.Variant("as", ["Test Artist"]),

View File

@ -4,9 +4,12 @@ import pathlib
import unittest import unittest
import emmental.db import emmental.db
from gi.repository import GObject from gi.repository import GObject
from gi.repository import GLib
TRACK_OGG = pathlib.Path(__file__).parent / "track.ogg" TRACK_OGG = pathlib.Path(__file__).parent / "track.ogg"
COVER_JPG = pathlib.Path(__file__).parent / "cover.jpg"
COVER_JPG_BYTES = GLib.Bytes(COVER_JPG.open("rb").read())
class TestCase(unittest.TestCase): class TestCase(unittest.TestCase):

BIN
tests/util/cover.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB