Removed dependency on aioreactive. Doing this my way.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2021-09-23 15:17:39 -04:00
parent 671444a6bb
commit 6090724d93
6 changed files with 140 additions and 91 deletions

29
poetry.lock generated
View File

@@ -1,14 +1,3 @@
[[package]]
name = "aioreactive"
version = "0.15.0"
description = "Async/await Reactive Tools for Python 3.9+"
category = "main"
optional = false
python-versions = ">=3.9"
[package.dependencies]
expression = "*"
[[package]]
name = "appdirs"
version = "1.4.4"
@@ -220,14 +209,6 @@ category = "dev"
optional = false
python-versions = ">=2.7"
[[package]]
name = "expression"
version = "1.1.0"
description = "Practical functional programming for Python 3.8+"
category = "main"
optional = false
python-versions = ">=3.8"
[[package]]
name = "filelock"
version = "3.0.12"
@@ -1215,13 +1196,9 @@ notebook = ">=4.4.1"
[metadata]
lock-version = "1.1"
python-versions = ">=3.9,<3.11"
content-hash = "2e75c062e40ed124d6ee8e3b3fa4ad143da1fe5ae1280ecc6cb09ae5b4dcd0f3"
content-hash = "ddaa8d2b25ca8be048674b27b4e49951e5cf9271898f709761dff967f7cc48f3"
[metadata.files]
aioreactive = [
{file = "aioreactive-0.15.0-py3-none-any.whl", hash = "sha256:3c7b903594e1eb15f0c16c1cfa96031e6c81371f999c50b081fe81dda463486a"},
{file = "aioreactive-0.15.0.tar.gz", hash = "sha256:f3724425faed6d6f41f1c10330246e16444d47670668bcc6c268946731b50a52"},
]
appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
@@ -1425,10 +1402,6 @@ entrypoints = [
{file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"},
{file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"},
]
expression = [
{file = "Expression-1.1.0-py3-none-any.whl", hash = "sha256:1179a4b2262d5d81565c0fcafbe722b09f8716a8aa88efe764846a563c6227b2"},
{file = "Expression-1.1.0.tar.gz", hash = "sha256:2ddd230462351a48029c4e7d6a57c36df78eb3540a2c0b3e1a129dcd4fe75eb9"},
]
filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},

View File

@@ -25,7 +25,6 @@ Changelog = "https://gitlab.com/xlorepdarkhelm/plex-playlist/releases"
python = ">=3.9,<3.11"
click = "^7.0"
PlexAPI = "^4.5.2"
aioreactive = "^0.15.0"
appdirs = "^1.4.4"
PyYAML = "^5.4.1"
gino = "^1.0.1"

View File

@@ -9,6 +9,8 @@ __all__ = ["APPNAME", "APPAUTHOR", "PATHS"]
APPNAME = "plex-playlist"
APPAUTHOR = "Cliff Hill"
DOWNLOAD_BATCH_SIZE = 1000
@dataclasses.dataclass
class Paths:

70
src/playlist/models.py Normal file
View File

@@ -0,0 +1,70 @@
"""Contains the data models used throughout the application."""
import dataclasses
import datetime
import functools
import typing
import desert # type: ignore [import]
import marshmallow # type: ignore [import]
import plexapi.audio # type: ignore [import]
class SchemaMeta(type):
"""Metaclass to access the schema for the given class."""
schema: typing.ClassVar[marshmallow.schema.Schema]
@property # type: ignore [no-redef,misc]
@functools.cache
def schema(cls) -> marshmallow.schema.Schema:
"""Return the schema object for this class."""
return desert.schema(cls)
@dataclasses.dataclass(frozen=True)
class Track(metaclass=SchemaMeta):
"""Model defining a Track object."""
id: int
track_num: int
title: str
artist: str
album_num: int
album: str
album_artist: str
duration: int
rating: typing.Optional[int]
comments: str
added: datetime.datetime
play_count: int
played: typing.Optional[datetime.datetime]
@classmethod
def load(
cls: typing.Type["Track"],
data: dict[str, typing.Union[int, str, None]],
) -> "Track":
"""Load the given data into a Track instance."""
return typing.cast(Track, cls.schema.load(data))
@classmethod
def from_plex(
cls: typing.Type["Track"],
track: plexapi.audio.Track,
) -> "Track":
"""Convert a Plex track object to an internal Track object."""
return cls(
id=track.ratingKey,
track_num=track.index,
title=track.title,
artist=track.artist().title,
album_num=track.parentIndex,
album=track.parentTitle,
album_artist=track.grandparentTitle,
duration=track.duration,
rating=track.userRating,
comments=track.summary,
added=track.addedAt,
play_count=track.viewCount,
played=track.lastViewedAt,
)

View File

@@ -1,79 +1,69 @@
"""Contains the code to communicate to the Plex server."""
import asyncio
import functools
import typing
import aioreactive as rx # type: ignore
import plexapi.audio # type: ignore
import plexapi.server # type: ignore
import plexapi.audio # type: ignore [import]
import plexapi.server # type: ignore [import]
from playlist import const
from playlist import models
from playlist import settings
__all__ = ["server", "gen_tracks"]
__all__ = ["server", "gen_tracks", "total_track_count"]
server = plexapi.server.PlexServer(**settings.get().creds.to_dict())
def gen_batched_tracks(
def _downloader(
pos: int,
size: int,
loop: asyncio.events.AbstractEventLoop,
search_tracks: typing.Callable, # type: ignore [type-arg]
) -> typing.Awaitable[list[plexapi.audio.Track]]:
return loop.run_in_executor(
None,
functools.partial(
search_tracks,
maxresults=size,
container_start=pos,
container_size=size,
),
)
async def gen_tracks(
*,
batch_size: int = 100,
) -> typing.Generator[list[plexapi.audio.Track], None, None]:
"""Generate Tracks from the server in `batch_size` batches."""
pos = 0
while 1:
batch = list(
server.library.section("Music").searchTracks(
maxresults=batch_size,
container_start=pos,
container_size=batch_size,
),
)
yield batch
if len(batch) < batch_size:
break
pos += batch_size
def track_mapper(
tracks: list[plexapi.audio.Track],
) -> list[dict[str, typing.Any]]:
return [
{
"id": track.ratingKey,
"track_num": track.index,
"title": track.title,
"artist": track.artist().title,
"album_num": track.parentIndex,
"album": track.parentTitle,
"album_artist": track.grandparentTitle,
"duration": track.duration,
"rating": track.userRating,
"comments": track.summary,
"added": track.addedAt,
"play_count": track.viewCount,
"played": track.lastViewedAt,
}
for track in tracks
]
async def to_obs(
tracks: list[dict[str, typing.Any]],
) -> rx.AsyncRx[dict[str, typing.Any]]:
return rx.AsyncRx.from_iterable(iter(tracks))
def gen_tracks(
*,
batch_size: int = 100,
) -> rx.AsyncIteratorObserver[dict[str, typing.Any]]:
batch_size: int = const.DOWNLOAD_BATCH_SIZE,
) -> typing.AsyncGenerator[models.Track, None]:
"""Generate all Tracks from the Server, asynchronously.
Keyword Args:
batch_size: determines how many Tracks are pulled from the Server at a time.
"""
source = rx.AsyncRx.from_iterable(gen_batched_tracks(batch_size=batch_size))
track_rx = rx.pipe(
source,
rx.map(track_mapper),
rx.flat_map_latest_async(to_obs),
quotient, remainder = divmod(await total_track_count(), batch_size)
loop = asyncio.get_running_loop()
search_tracks = server.library.section("Music").searchTracks
batches = [
_downloader(ndx, batch_size, loop, search_tracks) for ndx in range(quotient)
]
batches.append(_downloader(quotient, remainder, loop, search_tracks))
for batch in asyncio.as_completed(batches):
batch_gen = (models.Track.from_plex(plex_track) for plex_track in await batch)
for track in batch_gen:
yield track
async def total_track_count() -> int:
"""Get the total number of tracks in the server."""
loop = asyncio.get_running_loop()
num_tracks: int = await loop.run_in_executor(
None,
(
lambda: typing.cast(
int,
server.library.section("Music").totalViewSize("track"),
)
),
)
return rx.AsyncIteratorObserver(track_rx)
return num_tracks

15
src/playlist/utils.py Normal file
View File

@@ -0,0 +1,15 @@
"""Utility functions & classes used in the application."""
import contextlib
import datetime
import typing
@contextlib.contextmanager
def time_this() -> typing.Generator[None, None, None]:
"""Context manager to time the execution of a block of code."""
start = datetime.datetime.now()
try:
yield
finally:
end = datetime.datetime.now()
print(f"Elapsed time: {end - start}")