Skip to content
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

Improve audio streaming #740

Merged
merged 8 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions music_assistant/common/helpers/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,33 +129,37 @@ def get_version_substitute(version_str: str):
return version_str.strip()


def get_ip():
async def get_ip():
"""Get primary IP-address for this host."""
# pylint: disable=broad-except,no-member
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# doesn't even have to be reachable
sock.connect(("10.255.255.255", 1))
_ip = sock.getsockname()[0]
except Exception:
_ip = "127.0.0.1"
finally:
sock.close()
return _ip


def is_port_in_use(port: int) -> bool:
"""Check if port is in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:

def _get_ip():
"""Get primary IP-address for this host."""
# pylint: disable=broad-except,no-member
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
_sock.bind(("127.0.0.1", port))
except OSError:
return True
# doesn't even have to be reachable
sock.connect(("10.255.255.255", 1))
_ip = sock.getsockname()[0]
except Exception:
_ip = "127.0.0.1"
finally:
sock.close()
return _ip

return await asyncio.to_thread(_get_ip)


async def select_free_port(range_start: int, range_end: int) -> int:
"""Automatically find available port within range."""

def is_port_in_use(port: int) -> bool:
"""Check if port is in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as _sock:
try:
_sock.bind(("127.0.0.1", port))
except OSError:
return True

def _select_free_port():
for port in range(range_start, range_end):
if not is_port_in_use(port):
Expand All @@ -178,13 +182,15 @@ def _resolve():
return await asyncio.to_thread(_resolve)


def get_ip_pton(ip_string: str = get_ip()):
async def get_ip_pton(ip_string: str | None = None):
"""Return socket pton for local ip."""
if ip_string is None:
ip_string = await get_ip()
# pylint:disable=no-member
try:
return socket.inet_pton(socket.AF_INET, ip_string)
return await asyncio.to_thread(socket.inet_pton, socket.AF_INET, ip_string)
except OSError:
return socket.inet_pton(socket.AF_INET6, ip_string)
return await asyncio.to_thread(socket.inet_pton, socket.AF_INET6, ip_string)


def get_folder_size(folderpath):
Expand Down
11 changes: 11 additions & 0 deletions music_assistant/common/models/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ class PlayerConfig(Config):
default_name: str | None = None


@dataclass
class CoreConfig(Config):
"""CoreController Configuration."""

module: str # name of the core module
friendly_name: str # friendly name of the core module
last_error: str | None = None


CONF_ENTRY_LOG_LEVEL = ConfigEntry(
key=CONF_LOG_LEVEL,
type=ConfigEntryType.STRING,
Expand All @@ -282,6 +291,7 @@ class PlayerConfig(Config):
)

DEFAULT_PROVIDER_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)
DEFAULT_CORE_CONFIG_ENTRIES = (CONF_ENTRY_LOG_LEVEL,)

# some reusable player config entries

Expand Down Expand Up @@ -406,6 +416,7 @@ class PlayerConfig(Config):
advanced=True,
)


CONF_ENTRY_GROUPED_POWER_ON = ConfigEntry(
key=CONF_GROUPED_POWER_ON,
type=ConfigEntryType.BOOLEAN,
Expand Down
56 changes: 39 additions & 17 deletions music_assistant/common/models/media_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,21 @@
JOINED_KEYS = ("barcode", "isrc")


@dataclass(frozen=True)
class ProviderMapping(DataClassDictMixin):
"""Model for a MediaItem's provider mapping details."""
@dataclass
class AudioFormat(DataClassDictMixin):
"""Model for AudioFormat details."""

item_id: str
provider_domain: str
provider_instance: str
available: bool = True
# quality details (streamable content only)
content_type: ContentType = ContentType.UNKNOWN
sample_rate: int = 44100
bit_depth: int = 16
bit_rate: int = 320
# optional details to store provider specific details
details: str | None = None
# url = link to provider details page if exists
url: str | None = None
channels: int = 2
output_format_str: str = ""
bit_rate: int = 320 # optional

def __post_init__(self):
"""Execute actions after init."""
if not self.output_format_str:
self.output_format_str = self.content_type.value

@property
def quality(self) -> int:
Expand All @@ -55,6 +53,32 @@ def quality(self) -> int:
score += 1
return int(score)

@property
def pcm_sample_size(self) -> int:
"""Return the PCM sample size."""
return int(self.sample_rate * (self.bit_depth / 8) * self.channels)


@dataclass(frozen=True)
class ProviderMapping(DataClassDictMixin):
"""Model for a MediaItem's provider mapping details."""

item_id: str
provider_domain: str
provider_instance: str
available: bool = True
# quality/audio details (streamable content only)
audio_format: AudioFormat = field(default_factory=AudioFormat)
# optional details to store provider specific details
details: str | None = None
# url = link to provider details page if exists
url: str | None = None

@property
def quality(self) -> int:
"""Return quality score."""
return self.audio_format.quality

def __hash__(self) -> int:
"""Return custom hash."""
return hash((self.provider_instance, self.item_id))
Expand Down Expand Up @@ -523,11 +547,9 @@ class StreamDetails(DataClassDictMixin):
# mandatory fields
provider: str
item_id: str
content_type: ContentType
audio_format: AudioFormat
media_type: MediaType = MediaType.TRACK
sample_rate: int = 44100
bit_depth: int = 16
channels: int = 2

# stream_title: radio streams can optionally set this field
stream_title: str | None = None
# duration of the item to stream, copied from media_item if omitted
Expand Down
6 changes: 3 additions & 3 deletions music_assistant/common/models/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ class Player(DataClassDictMixin):
elapsed_time: float = 0
elapsed_time_last_updated: float = time.time()
current_url: str | None = None
current_item_id: str | None = None
state: PlayerState = PlayerState.IDLE

volume_level: int = 100
Expand All @@ -45,8 +44,9 @@ class Player(DataClassDictMixin):
# - If this player is a dedicated group player,
# returns all child id's of the players in the group.
# - If this is a syncgroup of players from the same platform (e.g. sonos),
# this will return the id's of players synced to this player.
group_childs: list[str] = field(default_factory=list)
# this will return the id's of players synced to this player,
# and this may include the player's own id.
group_childs: set[str] = field(default_factory=set)

# active_source: return player_id of the active queue for this player
# if the player is grouped and a group is active, this will be set to the group's player_id
Expand Down
4 changes: 4 additions & 0 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CONF_PORT: Final[str] = "port"
CONF_PROVIDERS: Final[str] = "providers"
CONF_PLAYERS: Final[str] = "players"
CONF_CORE: Final[str] = "core"
CONF_PATH: Final[str] = "path"
CONF_USERNAME: Final[str] = "username"
CONF_PASSWORD: Final[str] = "password"
Expand All @@ -48,6 +49,9 @@
CONF_OUTPUT_CODEC: Final[str] = "output_codec"
CONF_GROUPED_POWER_ON: Final[str] = "grouped_power_on"
CONF_CROSSFADE_DURATION: Final[str] = "crossfade_duration"
CONF_BIND_IP: Final[str] = "bind_ip"
CONF_BIND_PORT: Final[str] = "bind_port"
CONF_PUBLISH_IP: Final[str] = "publish_ip"

# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
Expand Down
21 changes: 12 additions & 9 deletions music_assistant/server/controllers/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,39 @@

import asyncio
import functools
import json
import logging
import os
import time
from collections import OrderedDict
from collections.abc import Iterator, MutableMapping
from typing import TYPE_CHECKING, Any

from music_assistant.common.helpers.json import json_dumps, json_loads
from music_assistant.constants import (
DB_TABLE_CACHE,
DB_TABLE_SETTINGS,
ROOT_LOGGER_NAME,
SCHEMA_VERSION,
)
from music_assistant.server.helpers.database import DatabaseConnection
from music_assistant.server.models.core_controller import CoreController

if TYPE_CHECKING:
from music_assistant.server import MusicAssistant
pass

LOGGER = logging.getLogger(f"{ROOT_LOGGER_NAME}.cache")


class CacheController:
class CacheController(CoreController):
"""Basic cache controller using both memory and database."""

database: DatabaseConnection | None = None
name: str = "cache"
friendly_name: str = "Cache controller"

def __init__(self, mass: MusicAssistant) -> None:
"""Initialize our caching class."""
self.mass = mass
def __init__(self, *args, **kwargs) -> None:
"""Initialize core controller."""
super().__init__(*args, **kwargs)
self.database: DatabaseConnection | None = None
self._mem_cache = MemoryCache(500)

async def setup(self) -> None:
Expand Down Expand Up @@ -66,7 +69,7 @@ async def get(self, cache_key: str, checksum: str | None = None, default=None):
not checksum or db_row["checksum"] == checksum and db_row["expires"] >= cur_time
):
try:
data = await asyncio.to_thread(json.loads, db_row["data"])
data = await asyncio.to_thread(json_loads, db_row["data"])
except Exception as exc: # pylint: disable=broad-except
LOGGER.exception("Error parsing cache data for %s", cache_key, exc_info=exc)
else:
Expand All @@ -90,7 +93,7 @@ async def set(self, cache_key, data, checksum="", expiration=(86400 * 30)):
if (expires - time.time()) < 3600 * 4:
# do not cache items in db with short expiration
return
data = await asyncio.to_thread(json.dumps, data)
data = await asyncio.to_thread(json_dumps, data)
await self.database.insert(
DB_TABLE_CACHE,
{"key": cache_key, "expires": expires, "checksum": checksum, "data": data},
Expand Down
Loading