-
Notifications
You must be signed in to change notification settings - Fork 24
Support album artwork ('albumart' command) and binary responses #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cf238f0
97e0ff2
7d7292f
12a7572
12ca0fa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
from mopidy_mpd import protocol | ||
from urllib.request import urlopen | ||
from mopidy_mpd.exceptions import MpdNoExistError, MpdArgError | ||
|
||
|
||
def _get_art_url(self, uri): | ||
images = self.core.library.get_images([uri]).get() | ||
if images[uri]: | ||
largest_image = sorted( | ||
images[uri], key=lambda i: i.width or 0, reverse=True | ||
)[0] | ||
return largest_image.uri | ||
|
||
|
||
cover_cache = {} | ||
|
||
|
||
@protocol.commands.add("albumart", offset=protocol.UINT) | ||
def albumart(context, uri, offset): | ||
""" | ||
*musicpd.org, the music database section:* | ||
|
||
``albumart {URI} {OFFSET}`` | ||
|
||
Locate album art for the given song and return a chunk | ||
of an album art image file at offset OFFSET. | ||
|
||
This is currently implemented by searching the directory | ||
the file resides in for a file called cover.png, cover.jpg, | ||
cover.tiff or cover.bmp. | ||
|
||
Returns the file size and actual number of bytes read at | ||
the requested offset, followed by the chunk requested as | ||
raw bytes (see Binary Responses), then a newline and the completion code. | ||
|
||
Example:: | ||
|
||
albumart foo/bar.ogg 0 | ||
size: 1024768 | ||
binary: 8192 | ||
<8192 bytes> | ||
OK | ||
|
||
.. versionadded:: 0.21 | ||
New in MPD protocol version 0.21 | ||
""" | ||
global cover_cache | ||
|
||
if uri not in cover_cache: | ||
art_url = _get_art_url(context, uri) | ||
|
||
if art_url is None: | ||
raise MpdNoExistError("No art file exists") | ||
|
||
cover_cache[uri] = urlopen(art_url).read() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs error handling. And does it need to support a timeout, retries, proxy config. Lots to handle here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A core API makes more and more sense here. This is a whole world of pain to add to Mopidy-MPD and it's really the job of each backend to provide data. Would a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jodal @adamcik do you have any thoughts about what to here?
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One additional remark about my part of the code that was not accepted. I had foreseen a maximum (preferred) image size for images retrieved from -Local. The current code uses the largest images available (line 8), which is very inefficient (slow) for MPD clients that prefetch the images for all local albums. Ideally max preferred image size would be a config setting, in -MPD and/or -Local. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @kingosticks I see. When I took recourse to I believe your suggestion 2 sounds like the most clean way forward (new core api I am currently implementing your other feedback – thanks for the through code-review :) – I'll leave this part of the code as is for the moment, as it's likely that things will move to the core (I did however implement There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A quick note aswell: I foreseeably won't be available during the upcoming week. I'll try to upload adjustments in response to your feedback before leaving as far as possible, leaving this very part out for now as described above. |
||
|
||
data = cover_cache[uri] | ||
|
||
total_size = len(data) | ||
chunk_size = context.dispatcher.config["mpd"]["albumart_chunk_size"] | ||
|
||
if offset > total_size: | ||
return MpdArgError("Bad file offset") | ||
|
||
size = min(chunk_size, total_size - offset) | ||
|
||
if offset + size >= total_size: | ||
cover_cache.pop(uri) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is OK for now but a possible enhancement would be to have a smarter FIFO style cache as there's a chance the client will ask for this image again. |
||
|
||
return b"size: %d\nbinary: %d\n%b" % ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't the response logging messed up for this? Can we return a dict or list here instead, with only the |
||
total_size, | ||
size, | ||
data[offset : offset + size], | ||
) | ||
|
||
|
||
# @protocol.commands.add("readpicture", offset=protocol.UINT) # not yet implemented | ||
def readpicture(context, uri, offset): | ||
""" | ||
*musicpd.org, the music database section:* | ||
|
||
``readpicture {URI} {OFFSET}`` | ||
|
||
Locate a picture for the given song and return a chunk | ||
of the image file at offset OFFSET. This is usually | ||
implemented by reading embedded pictures from | ||
binary tags (e.g. ID3v2's APIC tag). | ||
|
||
Returns the following values: | ||
|
||
* size: the total file size | ||
* type: the file's MIME type (optional) | ||
* binary: see Binary Responses | ||
|
||
If the song file was recognized, but there is no picture, | ||
the response is successful, but is otherwise empty. | ||
|
||
Example:: | ||
|
||
readpicture foo/bar.ogg 0 | ||
size: 1024768 | ||
type: image/jpeg | ||
binary: 8192 | ||
<8192 bytes> | ||
OK | ||
|
||
.. versionadded:: 0.21 | ||
New in MPD protocol version 0.21 | ||
""" | ||
# raise exceptions.MpdNotImplemented # TODO |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -45,7 +45,11 @@ def on_line_received(self, line): | |
logger.debug( | ||
"Response to %s: %s", | ||
self.connection, | ||
formatting.indent(self.decode(self.terminator).join(response)), | ||
formatting.indent( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As per my other comment, the formatting of binary responses seems like it'd be messed up. Is there a test that shows otherwise? |
||
self.decode(self.terminator).join( | ||
[str(line) for line in response] | ||
) | ||
), | ||
) | ||
|
||
self.send_lines(response) | ||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -211,32 +211,36 @@ def test_send_lines_calls_join_lines(self): | |||||||||
self.mock.connection = Mock(spec=network.Connection) | ||||||||||
self.mock.join_lines.return_value = "lines" | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be more accurate if this now used |
||||||||||
|
||||||||||
network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) | ||||||||||
self.mock.join_lines.assert_called_once_with(["line 1", "line 2"]) | ||||||||||
network.LineProtocol.send_lines( | ||||||||||
self.mock, ["line 1".encode(), "line 2".encode()] | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
) | ||||||||||
self.mock.join_lines.assert_called_once_with( | ||||||||||
["line 1".encode(), "line 2".encode()] | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
) | ||||||||||
|
||||||||||
def test_send_line_encodes_joined_lines_with_final_terminator(self): | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should prob rename this to reflect what's actually being tested here. |
||||||||||
self.mock.connection = Mock(spec=network.Connection) | ||||||||||
self.mock.join_lines.return_value = "lines\n" | ||||||||||
|
||||||||||
network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) | ||||||||||
self.mock.encode.assert_called_once_with("lines\n") | ||||||||||
network.LineProtocol.send_lines(self.mock, ["line 1"]) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
self.mock.encode.assert_called_once_with("line 1") | ||||||||||
|
||||||||||
def test_send_lines_sends_encoded_string(self): | ||||||||||
self.mock.connection = Mock(spec=network.Connection) | ||||||||||
self.mock.join_lines.return_value = "lines" | ||||||||||
self.mock.join_lines.return_value = sentinel.data | ||||||||||
self.mock.encode.return_value = sentinel.data | ||||||||||
|
||||||||||
network.LineProtocol.send_lines(self.mock, ["line 1", "line 2"]) | ||||||||||
self.mock.connection.queue_send.assert_called_once_with(sentinel.data) | ||||||||||
|
||||||||||
def test_join_lines_returns_empty_string_for_no_lines(self): | ||||||||||
assert "" == network.LineProtocol.join_lines(self.mock, []) | ||||||||||
assert b"" == network.LineProtocol.join_lines(self.mock, []) | ||||||||||
|
||||||||||
def test_join_lines_returns_joined_lines(self): | ||||||||||
self.mock.decode.return_value = "\n" | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove this line |
||||||||||
assert "1\n2\n" == network.LineProtocol.join_lines( | ||||||||||
self.mock, ["1", "2"] | ||||||||||
result = network.LineProtocol.join_lines( | ||||||||||
self.mock, ["1".encode(), "2".encode()] | ||||||||||
Comment on lines
+240
to
+241
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
) | ||||||||||
assert "1\n2\n".encode() == result | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
def test_decode_calls_decode_on_string(self): | ||||||||||
string = Mock() | ||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,53 @@ | ||||||||||
from io import BytesIO | ||||||||||
from mopidy_mpd.protocol import album_art | ||||||||||
from unittest import mock | ||||||||||
from mopidy.models import Album, Track, Image | ||||||||||
|
||||||||||
from tests import protocol | ||||||||||
|
||||||||||
|
||||||||||
def mock_get_images(self, uris): | ||||||||||
result = {} | ||||||||||
for uri in uris: | ||||||||||
result[uri] = [Image(uri="dummy:/albumart.jpg", width=128, height=128)] | ||||||||||
return result | ||||||||||
|
||||||||||
|
||||||||||
class AlbumArtTest(protocol.BaseTestCase): | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs tests for the image width sorting, caching, and |
||||||||||
def test_albumart_for_track_without_art(self): | ||||||||||
track = Track( | ||||||||||
uri="dummy:/à", | ||||||||||
name="a nàme", | ||||||||||
album=Album(uri="something:àlbum:12345"), | ||||||||||
) | ||||||||||
self.backend.library.dummy_library = [track] | ||||||||||
self.core.tracklist.add(uris=[track.uri]).get() | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to add to the tracklist and play the track in these tests? |
||||||||||
|
||||||||||
self.core.playback.play().get() | ||||||||||
|
||||||||||
self.send_request("albumart file:///home/test/music.flac 0") | ||||||||||
self.assertInResponse("ACK [50@0] {albumart} No art file exists") | ||||||||||
|
||||||||||
@mock.patch.object( | ||||||||||
protocol.core.library.LibraryController, "get_images", mock_get_images | ||||||||||
) | ||||||||||
def test_albumart(self): | ||||||||||
track = Track( | ||||||||||
uri="dummy:/à", | ||||||||||
name="a nàme", | ||||||||||
album=Album(uri="something:àlbum:12345"), | ||||||||||
) | ||||||||||
self.backend.library.dummy_library = [track] | ||||||||||
self.core.tracklist.add(uris=[track.uri]).get() | ||||||||||
|
||||||||||
self.core.playback.play().get() | ||||||||||
|
||||||||||
## | ||||||||||
expected = b"result" | ||||||||||
|
||||||||||
with mock.patch.object( | ||||||||||
album_art, "urlopen", return_value=BytesIO(expected) | ||||||||||
): | ||||||||||
self.send_request("albumart file:///home/test/music.flac 0") | ||||||||||
|
||||||||||
self.assertInResponse("binary: " + str(len(expected))) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Unfortunately, I think |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So how will this differ from what's eventually implemented for
readpicture
? This seems like it isreadpicture
. I don't actually understand why MPD has two different commands for this, why would an MPD client care if it was embedded art or from elsewhere? The problem being for us that a sensible "elsewhere" doesn't exist for many of our backends. It looks likereadpicture
was added later, whyreadpicture
doesn't fallback to usealbumart
seems odd to me.However, if the goal is to literally provide album art, then maybe we should be taking the song uri, get the album uri for it and then find artwork for that album instead? That sounds a bit closer to what the MPD docs describe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO, if there exists embedded audio art, this should be prioritized over url.