Added tests for get_playlists and get_playlist_details

This commit is contained in:
Sumner Evans
2020-04-20 21:26:10 -06:00
parent bd22954c96
commit 9d7218afd9
12 changed files with 397 additions and 246 deletions

View File

@@ -4,6 +4,7 @@ url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
dataclasses-json = {git = "https://github.com/sumnerevans/dataclasses-json",ref = "cc2eaeb"}
docutils = "*"
flake8 = "*"
flake8-annotations = "*"

9
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "71577f2c34a30d79301fb7a2c0c80e81d8bec18f7f7038b15ef38980f6c7bea7"
"sha256": "1b3ed7bc26fc014d648a0fddf4cde814f5ea583a464bf457548326a67825601c"
},
"pipfile-spec": 6,
"requires": {
@@ -418,6 +418,13 @@
],
"version": "==5.1"
},
"dataclasses-json": {
"hashes": [
"sha256:65ac9ae2f7ec152ee01bf42c8c024736d4cd6f6fb761502dec92bd553931e3d9",
"sha256:dbb53ebbac30ef45f44f5f436b21bd5726a80a14e1a193958864229100271372"
],
"version": "==0.4.2"
},
"docutils": {
"hashes": [
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",

View File

@@ -56,7 +56,7 @@ setup(
},
install_requires=[
'bottle',
'dataclasses-json',
'dataclasses-json @ git+https://github.com/sumnerevans/dataclasses-json@cc2eaeb#egg=dataclasses-json',
'deepdiff',
'Deprecated',
'fuzzywuzzy',

View File

@@ -1,7 +1,6 @@
import json
import logging
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
from time import sleep
@@ -53,7 +52,8 @@ class SubsonicAdapter(Adapter):
@property
def can_service_requests(self) -> bool:
try:
self._get_json('ping', timeout=2)
# Try to ping the server with a timeout of 2 seconds.
self._get_json(self._make_url('ping'), timeout=2)
return True
except Exception:
logging.exception(f'Could not connect to {self.hostname}')
@@ -98,6 +98,7 @@ class SubsonicAdapter(Adapter):
params[k] = int(v.timestamp() * 1000)
if self._is_mock:
logging.debug('Using mock data')
return self._get_mock_data()
result = requests.get(

View File

@@ -13,20 +13,24 @@ from dataclasses_json import (
DataClassJsonMixin,
LetterCase,
)
from dateutil import parser
from .. import api_objects as SublimeAPI
dataclasses_json.cfg.global_config.encoders[datetime] = datetime.isoformat
dataclasses_json.cfg.global_config.decoders[datetime] = parser.parse
dataclasses_json.cfg.global_config.encoders[timedelta] = (
timedelta.total_seconds)
dataclasses_json.cfg.global_config.decoders[timedelta] = lambda s: timedelta(
seconds=s)
# Translation map
extra_translation_map = {
datetime:
(lambda s: datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') if s else None),
timedelta: (lambda s: timedelta(seconds=s) if s else None),
}
for type_, translation_function in extra_translation_map.items():
dataclasses_json.cfg.global_config.decoders[type_] = translation_function
dataclasses_json.cfg.global_config.decoders[
Optional[type_]] = translation_function
@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass(frozen=True)
@dataclass
class Child(SublimeAPI.Song):
id: str
title: str
@@ -81,7 +85,8 @@ class Playlist(SublimeAPI.Playlist):
class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
id: str
name: str
songs: List[Child] = field(metadata=config(field_name='entry'))
songs: List[Child] = field(
default_factory=list, metadata=config(field_name='entry'))
song_count: int = field(default=0)
duration: timedelta = field(default=timedelta())
created: Optional[datetime] = None
@@ -97,12 +102,12 @@ class PlaylistWithSongs(SublimeAPI.PlaylistDetails):
seconds=sum(s.duration for s in self.songs))
@dataclass(frozen=True)
@dataclass
class Playlists(DataClassJsonMixin):
playlist: List[Playlist] = field(default_factory=list)
@dataclass(frozen=True)
@dataclass
class Response(DataClassJsonMixin):
"""
The base Subsonic response object.

View File

@@ -0,0 +1,74 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"playlist": {
"id": "2",
"name": "Christian",
"comment": "",
"owner": "sumner",
"public": true,
"songCount": 2,
"duration": 470,
"created": "2020-03-27T05:38:45.487Z",
"changed": "2020-04-09T16:03:26.039Z",
"coverArt": "pl-2",
"entry": [
{
"id": "202",
"parent": "318",
"isDir": false,
"title": "What a Beautiful Name",
"album": "What a Beautiful Name - Single",
"artist": "Hillsong Worship",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "318",
"size": 8381640,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 238,
"bitRate": 256,
"path": "Hillsong Worship/What a Beautiful Name - Single/01 What a Beautiful Name.m4a",
"isVideo": false,
"playCount": 20,
"discNumber": 1,
"created": "2020-03-27T05:17:07.000Z",
"albumId": "48",
"artistId": "38",
"type": "music"
},
{
"id": "203",
"parent": "319",
"isDir": false,
"title": "Great Are You Lord",
"album": "Great Are You Lord EP",
"artist": "one sonic society",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "319",
"size": 8245813,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 232,
"bitRate": 256,
"path": "one sonic society/Great Are You Lord EP/01 Great Are You Lord.m4a",
"isVideo": false,
"playCount": 18,
"discNumber": 1,
"created": "2020-03-27T05:32:42.000Z",
"albumId": "10",
"artistId": "8",
"type": "music"
}
]
}
}
}

View File

@@ -0,0 +1,72 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"playlist": {
"id": "2",
"name": "Christian",
"comment": "",
"owner": "sumner",
"public": true,
"created": "2020-03-27T05:38:45.487Z",
"changed": "2020-04-09T16:03:26.039Z",
"coverArt": "pl-2",
"entry": [
{
"id": "202",
"parent": "318",
"isDir": false,
"title": "What a Beautiful Name",
"album": "What a Beautiful Name - Single",
"artist": "Hillsong Worship",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "318",
"size": 8381640,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 238,
"bitRate": 256,
"path": "Hillsong Worship/What a Beautiful Name - Single/01 What a Beautiful Name.m4a",
"isVideo": false,
"playCount": 20,
"discNumber": 1,
"created": "2020-03-27T05:17:07.000Z",
"albumId": "48",
"artistId": "38",
"type": "music"
},
{
"id": "203",
"parent": "319",
"isDir": false,
"title": "Great Are You Lord",
"album": "Great Are You Lord EP",
"artist": "one sonic society",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "319",
"size": 8245813,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 232,
"bitRate": 256,
"path": "one sonic society/Great Are You Lord EP/01 Great Are You Lord.m4a",
"isVideo": false,
"playCount": 18,
"discNumber": 1,
"created": "2020-03-27T05:32:42.000Z",
"albumId": "10",
"artistId": "8",
"type": "music"
}
]
}
}
}

View File

@@ -0,0 +1,34 @@
{
"subsonic-response": {
"status": "ok",
"version": "1.15.0",
"playlists": {
"playlist": [
{
"id": "2",
"name": "Test",
"comment": "Foo",
"owner": "foo",
"public": true,
"songCount": 132,
"duration": 33072,
"created": "2020-03-27T05:38:45.000Z",
"changed": "2020-04-09T16:03:26.000Z",
"coverArt": "pl-2"
},
{
"id": "3",
"name": "Bar",
"comment": "",
"owner": "foo",
"public": false,
"songCount": 23,
"duration": 847,
"created": "2020-03-27T05:39:04.000Z",
"changed": "2020-03-27T05:45:23.000Z",
"coverArt": "pl-3"
}
]
}
}
}

View File

@@ -0,0 +1,3 @@
{
"I'm json from another service"
}

View File

@@ -0,0 +1,7 @@
<html>
<head><title>400 Not Found</title></head>
<body>
<center><h1>400 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

View File

@@ -1,231 +0,0 @@
import json
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Dict, Tuple
import pytest
from dateutil import parser
from sublime.adapters.subsonic import (
SubsonicAdapter, api_objects as SubsonicAPI)
@pytest.fixture
def subsonic_adapter(tmp_path: Path):
adapter = SubsonicAdapter(
{
'server_address': 'http://localhost:4533',
'username': 'test',
'password': 'testpass',
},
tmp_path,
)
adapter._is_mock = True
yield adapter
def mock_json(**obj: Any) -> str:
return json.dumps(
{
'subsonic-response': {
'status': 'ok',
'version': '1.15.0',
**obj,
},
})
def camel_to_snake(name: str) -> str:
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
def test_request_making_methods(subsonic_adapter: SubsonicAdapter):
expected = {
'u': 'test',
'p': 'testpass',
'c': 'Sublime Music',
'f': 'json',
'v': '1.15.0',
}
assert (
sorted(expected.items()) == sorted(
subsonic_adapter._get_params().items()))
assert subsonic_adapter._make_url(
'foo') == 'http://localhost:4533/rest/foo.view'
def test_can_service_requests(subsonic_adapter: SubsonicAdapter):
# Mock a connection error
subsonic_adapter._set_mock_data(Exception())
assert subsonic_adapter.can_service_requests is False
# Simulate some sort of ping error
subsonic_adapter._set_mock_data(
mock_json(
status='failed',
error={
'code': '1',
'message': 'Test message',
},
))
assert subsonic_adapter.can_service_requests is False
# Simulate valid ping
subsonic_adapter._set_mock_data(mock_json())
assert subsonic_adapter.can_service_requests is True
def test_get_playlists(subsonic_adapter: SubsonicAdapter):
playlists = [
{
"id": "6",
"name": "Playlist 1",
"comment": "Foo",
"owner": "test",
"public": True,
"songCount": 2,
"duration": 625,
"created": "2020-03-27T05:39:35.188Z",
"changed": "2020-04-08T00:07:01.748Z",
"coverArt": "pl-6"
},
{
"id": "7",
"name": "Playlist 2",
"comment": "",
"owner": "test",
"public": True,
"songCount": 3,
"duration": 952,
"created": "2020-03-27T05:39:43.327Z",
"changed": "2020-03-27T05:44:37.275Z",
"coverArt": "pl-7"
},
]
subsonic_adapter._set_mock_data(
mock_json(playlists={
'playlist': playlists,
}))
expected = [
SubsonicAPI.Playlist(
id='6',
name='Playlist 1',
song_count=2,
duration=timedelta(seconds=625),
created=parser.parse('2020-03-27T05:39:35.188Z'),
changed=parser.parse('2020-04-08T00:07:01.748Z'),
comment='Foo',
owner='test',
public=True,
cover_art='pl-6',
),
SubsonicAPI.Playlist(
id='7',
name='Playlist 2',
song_count=3,
duration=timedelta(seconds=952),
created=parser.parse('2020-03-27T05:39:43.327Z'),
changed=parser.parse('2020-03-27T05:44:37.275Z'),
comment='',
owner='test',
public=True,
cover_art='pl-7',
),
]
assert subsonic_adapter.get_playlists() == expected
# When playlists is null, expect an empty list.
subsonic_adapter._set_mock_data(mock_json())
assert subsonic_adapter.get_playlists() == []
def test_get_playlist_details(subsonic_adapter: SubsonicAdapter):
playlist = {
"id":
"6",
"name":
"Playlist 1",
"comment":
"Foo",
"owner":
"test",
"public":
True,
"songCount":
2,
"duration":
952,
"created":
"2020-03-27T05:39:43.327Z",
"changed":
"2020-03-27T05:44:37.275Z",
"coverArt":
"pl-6",
"entry": [
{
"id": "202",
"parent": "318",
"isDir": False,
"title": "What a Beautiful Name",
"album": "What a Beautiful Name - Single",
"artist": "Hillsong Worship",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "318",
"size": 8381640,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 238,
"bitRate": 256,
"path":
"Hillsong Worship/What a Beautiful Name - Single/01 What a Beautiful Name.m4a",
"isVideo": False,
"playCount": 20,
"discNumber": 1,
"created": "2020-03-27T05:17:07.000Z",
"albumId": "48",
"artistId": "38",
"type": "music"
},
{
"id": "203",
"parent": "319",
"isDir": False,
"title": "Great Are You Lord",
"album": "Great Are You Lord EP",
"artist": "one sonic society",
"track": 1,
"year": 2016,
"genre": "Christian & Gospel",
"coverArt": "319",
"size": 8245813,
"contentType": "audio/mp4",
"suffix": "m4a",
"transcodedContentType": "audio/mpeg",
"transcodedSuffix": "mp3",
"duration": 232,
"bitRate": 256,
"path":
"one sonic society/Great Are You Lord EP/01 Great Are You Lord.m4a",
"isVideo": False,
"playCount": 18,
"discNumber": 1,
"created": "2020-03-27T05:32:42.000Z",
"albumId": "10",
"artistId": "8",
"type": "music"
},
],
}
subsonic_adapter._set_mock_data(mock_json(playlist=playlist))
playlist_details = subsonic_adapter.get_playlist_details('6')
assert len(playlist_details.songs) == 2

View File

@@ -0,0 +1,178 @@
import importlib
import importlib.util
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Generator, Optional, Tuple
import pytest
from sublime.adapters.subsonic import (
api_objects as SubsonicAPI,
SubsonicAdapter,
)
MOCK_DATA_FILES = Path(__file__).parent.joinpath('mock_data')
@pytest.fixture
def adapter(tmp_path: Path):
adapter = SubsonicAdapter(
{
'server_address': 'http://subsonic.example.com',
'username': 'test',
'password': 'testpass',
},
tmp_path,
)
adapter._is_mock = True
yield adapter
def mock_data_files(
request_name: str,
mode: str = 'r',
) -> Generator[str, None, None]:
"""
Yields all of the files in the mock_data directory that start with
``request_name``.
"""
for file in MOCK_DATA_FILES.iterdir():
if file.name.split('-')[0] in request_name:
with open(file, mode) as f:
yield file, f.read()
def mock_json(**obj: Any) -> str:
return json.dumps(
{
'subsonic-response': {
'status': 'ok',
'version': '1.15.0',
**obj,
},
})
def camel_to_snake(name: str) -> str:
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower()
def test_request_making_methods(adapter: SubsonicAdapter):
expected = {
'u': 'test',
'p': 'testpass',
'c': 'Sublime Music',
'f': 'json',
'v': '1.15.0',
}
assert (sorted(expected.items()) == sorted(adapter._get_params().items()))
assert adapter._make_url(
'foo') == 'http://subsonic.example.com/rest/foo.view'
def test_can_service_requests(adapter: SubsonicAdapter):
# Mock a connection error
adapter._set_mock_data(Exception())
assert adapter.can_service_requests is False
# Simulate some sort of ping error
for filename, data in mock_data_files('ping_failed'):
logging.info(filename)
logging.debug(data)
adapter._set_mock_data(data)
assert adapter.can_service_requests is False
# Simulate valid ping
adapter._set_mock_data(mock_json())
assert adapter.can_service_requests is True
def test_get_playlists(adapter: SubsonicAdapter):
expected = [
SubsonicAPI.Playlist(
id='2',
name='Test',
song_count=132,
duration=timedelta(seconds=33072),
created=datetime(2020, 3, 27, 5, 38, 45, 0, tzinfo=timezone.utc),
changed=datetime(2020, 4, 9, 16, 3, 26, 0, tzinfo=timezone.utc),
comment='Foo',
owner='foo',
public=True,
cover_art='pl-2',
),
SubsonicAPI.Playlist(
id='3',
name='Bar',
song_count=23,
duration=timedelta(seconds=847),
created=datetime(2020, 3, 27, 5, 39, 4, 0, tzinfo=timezone.utc),
changed=datetime(2020, 3, 27, 5, 45, 23, 0, tzinfo=timezone.utc),
comment='',
owner='foo',
public=False,
cover_art='pl-3',
),
]
for filename, data in mock_data_files('get_playlists'):
logging.info(filename)
logging.debug(data)
adapter._set_mock_data(data)
assert adapter.get_playlists() == expected
# When playlists is null, expect an empty list.
adapter._set_mock_data(mock_json())
assert adapter.get_playlists() == []
def test_get_playlist_details(adapter: SubsonicAdapter):
for filename, data in mock_data_files('get_playlist_details'):
logging.info(filename)
logging.debug(data)
adapter._set_mock_data(data)
playlist_details = adapter.get_playlist_details('2')
# Make sure that the song count is correct even if it's not provided.
# Old versions of Subsonic don't have these properties.
assert len(playlist_details.songs) == 2
assert playlist_details.duration == timedelta(seconds=470)
# Make sure that at least the first song got decoded properly.
assert playlist_details.songs[0] == SubsonicAPI.Child(
id='202',
parent='318',
title='What a Beautiful Name',
album='What a Beautiful Name - Single',
artist='Hillsong Worship',
track=1,
year=2016,
genre='Christian & Gospel',
cover_art='318',
size=8381640,
content_type="audio/mp4",
suffix="m4a",
transcoded_content_type="audio/mpeg",
transcoded_suffix="mp3",
duration=238,
bit_rate=256,
path='/'.join(
(
'Hillsong Worship',
'What a Beautiful Name - Single',
'01 What a Beautiful Name.m4a',
)),
is_video=False,
play_count=20,
disc_number=1,
created=datetime(2020, 3, 27, 5, 17, 7, tzinfo=timezone.utc),
album_id="48",
artist_id="38",
type=SubsonicAPI.SublimeAPI.MediaType.MUSIC,
)