artemis-model 0.1.112__tar.gz → 0.1.182__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {artemis_model-0.1.112 → artemis_model-0.1.182}/PKG-INFO +3 -2
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/__init__.py +5 -0
- artemis_model-0.1.182/artemis_model/approved_playlist_list.py +55 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/auth.py +79 -1
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/banned_tracks.py +4 -1
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/category.py +5 -12
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/location.py +8 -0
- artemis_model-0.1.182/artemis_model/location_genre_exclusion.py +38 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/organization.py +21 -0
- artemis_model-0.1.182/artemis_model/organization_include_pal_setting.py +32 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/playlist.py +3 -1
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/redis/__init__.py +13 -2
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/redis/bucket.py +7 -19
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/redis/device.py +1 -1
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/redis/keys.py +4 -2
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/redis/play_history.py +3 -8
- artemis_model-0.1.182/artemis_model/redis/zone_state.py +111 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/setting.py +1 -1
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/sqs/messages.py +25 -6
- artemis_model-0.1.182/artemis_model/zone_activity.py +48 -0
- artemis_model-0.1.182/artemis_model/zone_state.py +88 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/pyproject.toml +1 -1
- artemis_model-0.1.112/artemis_model/redis/zone_state.py +0 -57
- {artemis_model-0.1.112 → artemis_model-0.1.182}/README.md +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/album.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/artist.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/base.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/dj_set.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/genre.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/message.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/otp.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/permission.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/schedule.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/sqs/__init__.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/track.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/user.py +0 -0
- {artemis_model-0.1.112 → artemis_model-0.1.182}/artemis_model/zone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: artemis-model
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.182
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Jukeboxy
|
|
6
6
|
Requires-Python: >=3.10.6,<4.0.0
|
|
@@ -8,6 +8,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
8
8
|
Classifier: Programming Language :: Python :: 3.11
|
|
9
9
|
Classifier: Programming Language :: Python :: 3.12
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
12
|
Requires-Dist: alembic (>=1.13.1,<2.0.0)
|
|
12
13
|
Requires-Dist: asyncpg (>=0.30.0,<0.31.0)
|
|
13
14
|
Requires-Dist: greenlet (>=3.0.2,<4.0.0)
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
from artemis_model.album import * # noqa
|
|
2
|
+
from artemis_model.approved_playlist_list import * # noqa
|
|
2
3
|
from artemis_model.artist import * # noqa
|
|
3
4
|
from artemis_model.auth import * # noqa
|
|
4
5
|
from artemis_model.category import * # noqa
|
|
5
6
|
from artemis_model.dj_set import * # noqa
|
|
6
7
|
from artemis_model.genre import * # noqa
|
|
7
8
|
from artemis_model.location import * # noqa
|
|
9
|
+
from artemis_model.location_genre_exclusion import * # noqa
|
|
8
10
|
from artemis_model.message import * # noqa
|
|
9
11
|
from artemis_model.organization import * # noqa
|
|
12
|
+
from artemis_model.organization_include_pal_setting import * # noqa
|
|
10
13
|
from artemis_model.playlist import * # noqa
|
|
11
14
|
from artemis_model.schedule import * # noqa
|
|
12
15
|
from artemis_model.setting import * # noqa
|
|
@@ -15,3 +18,5 @@ from artemis_model.user import * # noqa
|
|
|
15
18
|
from artemis_model.zone import * # noqa
|
|
16
19
|
from artemis_model.otp import * # noqa
|
|
17
20
|
from artemis_model.banned_tracks import * # noqa
|
|
21
|
+
from artemis_model.zone_activity import * # noqa
|
|
22
|
+
from artemis_model.zone_state import * # noqa
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import ForeignKey
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
7
|
+
|
|
8
|
+
from artemis_model.base import CustomSyncBase, TimeStampMixin, AuditMixin, CustomBase
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApprovedPlaylistListMixin(TimeStampMixin, AuditMixin):
|
|
12
|
+
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True, index=True)
|
|
13
|
+
name: Mapped[str] = mapped_column(nullable=False)
|
|
14
|
+
organization_id: Mapped[uuid.UUID] = mapped_column(
|
|
15
|
+
ForeignKey("organization.id"), nullable=False, index=True
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
@declared_attr
|
|
19
|
+
def organization(cls) -> Mapped["Organization"]:
|
|
20
|
+
return relationship("Organization", back_populates="approved_playlist_lists")
|
|
21
|
+
|
|
22
|
+
@declared_attr
|
|
23
|
+
def playlist_associations(cls) -> Mapped[List["ApprovedPlaylistListPlaylistAssoc"]]:
|
|
24
|
+
return relationship(cascade="all, delete-orphan")
|
|
25
|
+
|
|
26
|
+
@declared_attr
|
|
27
|
+
def playlists(cls) -> Mapped[List["Playlist"]]:
|
|
28
|
+
return relationship(
|
|
29
|
+
"Playlist", secondary="approved_playlist_list_playlist_assoc", viewonly=True
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApprovedPlaylistListSync(CustomSyncBase, ApprovedPlaylistListMixin):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApprovedPlaylistList(CustomBase, ApprovedPlaylistListMixin):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApprovedPlaylistListPlaylistAssocMixin(TimeStampMixin):
|
|
42
|
+
approved_playlist_list_id: Mapped[int] = mapped_column(
|
|
43
|
+
ForeignKey("approved_playlist_list.id"), primary_key=True, nullable=False
|
|
44
|
+
)
|
|
45
|
+
playlist_id: Mapped[int] = mapped_column(
|
|
46
|
+
ForeignKey("playlist.id"), primary_key=True, nullable=False
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ApprovedPlaylistListPlaylistAssocSync(CustomSyncBase, ApprovedPlaylistListPlaylistAssocMixin):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ApprovedPlaylistListPlaylistAssoc(CustomBase, ApprovedPlaylistListPlaylistAssocMixin):
|
|
55
|
+
pass
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Auth models."""
|
|
2
2
|
|
|
3
|
+
from enum import Enum
|
|
3
4
|
import uuid
|
|
4
5
|
from datetime import datetime
|
|
5
6
|
from typing import Optional
|
|
6
7
|
|
|
8
|
+
from pydantic import BaseModel
|
|
7
9
|
from sqlalchemy import UUID, DateTime, ForeignKey, LargeBinary
|
|
8
10
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
11
|
from sqlalchemy.ext.declarative import declared_attr
|
|
10
12
|
|
|
11
|
-
from artemis_model.base import CustomSyncBase, TimeStampMixin, CustomBase
|
|
13
|
+
from artemis_model.base import AuditMixin, CustomSyncBase, TimeStampMixin, CustomBase
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class UserUnverifiedAccountMixin(TimeStampMixin):
|
|
@@ -111,3 +113,79 @@ class OAuthCsrfStateSync(CustomSyncBase, OAuthCsrfStateMixin):
|
|
|
111
113
|
|
|
112
114
|
class OAuthCsrfState(CustomBase, OAuthCsrfStateMixin):
|
|
113
115
|
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class OrionWebplayerCodeMixin(TimeStampMixin, AuditMixin):
|
|
119
|
+
"""Orion webplayer code mixin."""
|
|
120
|
+
|
|
121
|
+
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
122
|
+
code_hash: Mapped[str] = mapped_column(
|
|
123
|
+
unique=True, nullable=False
|
|
124
|
+
) # Argon2id of normalized code
|
|
125
|
+
name: Mapped[str] = mapped_column(nullable=False)
|
|
126
|
+
zone_id: Mapped[int] = mapped_column(nullable=False, index=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class OrionWebplayerCodeSync(CustomSyncBase, OrionWebplayerCodeMixin):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class OrionWebplayerCode(CustomBase, OrionWebplayerCodeMixin):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PlayerRefreshTokenMixin(TimeStampMixin):
|
|
138
|
+
"""
|
|
139
|
+
Refresh token for Player.
|
|
140
|
+
We look up by token_id (PK), then verify `secret` against `secret_hash`.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
# this is the public part we send to the client
|
|
144
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
145
|
+
UUID(as_uuid=True),
|
|
146
|
+
primary_key=True,
|
|
147
|
+
default=uuid.uuid4,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
code_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
|
151
|
+
UUID(as_uuid=True),
|
|
152
|
+
ForeignKey("orion_webplayer_code.id", ondelete="SET NULL"),
|
|
153
|
+
index=True,
|
|
154
|
+
nullable=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# store zone_id too (helps auth decisions / debugging)
|
|
158
|
+
zone_id: Mapped[Optional[int]] = mapped_column(index=True)
|
|
159
|
+
|
|
160
|
+
# bcrypt/argon2 hash of the secret part
|
|
161
|
+
secret_hash: Mapped[str] = mapped_column(nullable=False)
|
|
162
|
+
is_suspended: Mapped[bool] = mapped_column(default=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class PlayerRefreshTokenSync(CustomSyncBase, PlayerRefreshTokenMixin):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PlayerRefreshToken(CustomBase, PlayerRefreshTokenMixin):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Scope(str, Enum):
|
|
174
|
+
"""Scope enum."""
|
|
175
|
+
|
|
176
|
+
PLAYER = "player"
|
|
177
|
+
MANAGE = "manage"
|
|
178
|
+
|
|
179
|
+
class TokenData(BaseModel):
|
|
180
|
+
"""Token data."""
|
|
181
|
+
|
|
182
|
+
account_id: uuid.UUID
|
|
183
|
+
user_id: uuid.UUID
|
|
184
|
+
scope: Scope
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class RefreshTokenData(TokenData):
|
|
188
|
+
"""Refresh token data."""
|
|
189
|
+
|
|
190
|
+
zone_id: int | None = None
|
|
191
|
+
code_id: uuid.UUID | None = None
|
|
@@ -6,16 +6,19 @@ from artemis_model.base import AuditMixin, TimeStampMixin, CustomSyncBase, Custo
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class BannedTracksMixin(TimeStampMixin, AuditMixin):
|
|
9
|
-
"""Banned Tracks per Zone Model"""
|
|
9
|
+
"""Banned Tracks per Zone Model"""
|
|
10
|
+
|
|
10
11
|
zone_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
11
12
|
track_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class BannedTracksSync(CustomSyncBase, BannedTracksMixin):
|
|
15
16
|
"""Banned Tracks per Zone Model"""
|
|
17
|
+
|
|
16
18
|
pass
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class BannedTracks(CustomBase, BannedTracksMixin):
|
|
20
22
|
"""Banned Tracks per Zone Model"""
|
|
23
|
+
|
|
21
24
|
pass
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from sqlalchemy import
|
|
1
|
+
from sqlalchemy import Computed, func, literal, text
|
|
2
2
|
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
|
3
3
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
4
4
|
|
|
@@ -16,12 +16,12 @@ def to_tsvector_ix(*columns):
|
|
|
16
16
|
|
|
17
17
|
class CategoryMixin(TimeStampMixin):
|
|
18
18
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
main_category: Mapped[str] = mapped_column(nullable=False)
|
|
20
|
+
sub_category: Mapped[str] = mapped_column(nullable=False)
|
|
21
21
|
|
|
22
22
|
name_tsv = mapped_column(
|
|
23
23
|
TSVector(),
|
|
24
|
-
Computed("to_tsvector('english',
|
|
24
|
+
Computed("to_tsvector('english', main_category || ' ' || sub_category)", persisted=True),
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
@declared_attr
|
|
@@ -39,18 +39,11 @@ class CategoryMixin(TimeStampMixin):
|
|
|
39
39
|
# __table_args__ = (
|
|
40
40
|
# Index(
|
|
41
41
|
# "fts_ix_category_name_tsv",
|
|
42
|
-
# to_tsvector_ix("
|
|
42
|
+
# to_tsvector_ix("main_category", "sub_category"),
|
|
43
43
|
# postgresql_using="gin",
|
|
44
44
|
# ),
|
|
45
45
|
# )
|
|
46
46
|
|
|
47
|
-
@property
|
|
48
|
-
def is_subcategory(self) -> bool:
|
|
49
|
-
if self.sub_categories:
|
|
50
|
-
return True
|
|
51
|
-
|
|
52
|
-
return False
|
|
53
|
-
|
|
54
47
|
|
|
55
48
|
class CategorySync(CustomSyncBase, CategoryMixin):
|
|
56
49
|
pass
|
|
@@ -86,6 +86,14 @@ class LocationMixin(TimeStampMixin):
|
|
|
86
86
|
cascade="all, delete-orphan",
|
|
87
87
|
)
|
|
88
88
|
|
|
89
|
+
@declared_attr
|
|
90
|
+
def genre_exclusions(cls) -> Mapped[list["LocationGenreExclusion"]]:
|
|
91
|
+
return relationship(
|
|
92
|
+
"LocationGenreExclusion",
|
|
93
|
+
back_populates="location",
|
|
94
|
+
cascade="all, delete-orphan",
|
|
95
|
+
)
|
|
96
|
+
|
|
89
97
|
timezone: Mapped[str] = mapped_column(nullable=False, default="America/New_York")
|
|
90
98
|
|
|
91
99
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from sqlalchemy import ForeignKey, UniqueConstraint
|
|
3
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
4
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
5
|
+
|
|
6
|
+
from artemis_model.base import TimeStampMixin, CustomSyncBase, CustomBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocationGenreExclusionMixin(TimeStampMixin):
|
|
10
|
+
"""Association table for tracking which genres are excluded at the location level."""
|
|
11
|
+
|
|
12
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
13
|
+
location_id: Mapped[uuid.UUID] = mapped_column(
|
|
14
|
+
ForeignKey("location.id", ondelete="CASCADE"), nullable=False, index=True
|
|
15
|
+
)
|
|
16
|
+
genre_id: Mapped[int] = mapped_column(
|
|
17
|
+
ForeignKey("genre.id", ondelete="CASCADE"), nullable=False, index=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@declared_attr
|
|
21
|
+
def location(cls) -> Mapped["Location"]:
|
|
22
|
+
return relationship(back_populates="genre_exclusions")
|
|
23
|
+
|
|
24
|
+
@declared_attr
|
|
25
|
+
def genre(cls) -> Mapped["Genre"]:
|
|
26
|
+
return relationship()
|
|
27
|
+
|
|
28
|
+
__table_args__ = (
|
|
29
|
+
UniqueConstraint("location_id", "genre_id", name="unique_location_genre_exclusion"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocationGenreExclusionSync(CustomSyncBase, LocationGenreExclusionMixin):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LocationGenreExclusion(CustomBase, LocationGenreExclusionMixin):
|
|
38
|
+
pass
|
|
@@ -38,6 +38,27 @@ class OrganizationMixin(TimeStampMixin):
|
|
|
38
38
|
def message_groups(cls) -> Mapped[List["MessageGroup"]]:
|
|
39
39
|
return relationship(back_populates="organization")
|
|
40
40
|
|
|
41
|
+
@declared_attr
|
|
42
|
+
def include_pal_setting(cls) -> Mapped["OrganizationIncludePalSetting"]:
|
|
43
|
+
return relationship(
|
|
44
|
+
back_populates="organization", uselist=False, cascade="all, delete-orphan"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@declared_attr
|
|
48
|
+
def approved_playlist_lists(cls) -> Mapped[List["ApprovedPlaylistList"]]:
|
|
49
|
+
return relationship("ApprovedPlaylistList", back_populates="organization")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def approved_playlists(self) -> List[int]:
|
|
53
|
+
"""
|
|
54
|
+
Convenience property to get all playlist IDs from all approved lists, flattened.
|
|
55
|
+
Returns empty list if no approved lists exist.
|
|
56
|
+
"""
|
|
57
|
+
playlist_ids = []
|
|
58
|
+
for approved_list in self.approved_playlist_lists:
|
|
59
|
+
playlist_ids.extend([playlist.id for playlist in approved_list.playlists])
|
|
60
|
+
return playlist_ids
|
|
61
|
+
|
|
41
62
|
|
|
42
63
|
def generate_slug(target: Any, value: Any, old_value: Any, initiator: Any) -> None:
|
|
43
64
|
"""Creates a reasonable slug based on organization name."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from sqlalchemy import ForeignKey, UniqueConstraint
|
|
3
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
4
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
5
|
+
|
|
6
|
+
from artemis_model.base import TimeStampMixin, CustomSyncBase, CustomBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrganizationIncludePalSettingMixin(TimeStampMixin):
|
|
10
|
+
"""Organization-level setting for including parental advisory (explicit) content."""
|
|
11
|
+
|
|
12
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
13
|
+
organization_id: Mapped[uuid.UUID] = mapped_column(
|
|
14
|
+
ForeignKey("organization.id", ondelete="CASCADE"), nullable=False, index=True
|
|
15
|
+
)
|
|
16
|
+
include_pal: Mapped[bool] = mapped_column(nullable=False, default=False)
|
|
17
|
+
|
|
18
|
+
@declared_attr
|
|
19
|
+
def organization(cls) -> Mapped["Organization"]:
|
|
20
|
+
return relationship(back_populates="include_pal_setting")
|
|
21
|
+
|
|
22
|
+
__table_args__ = (
|
|
23
|
+
UniqueConstraint("organization_id", name="unique_organization_include_pal_setting"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OrganizationIncludePalSettingSync(CustomSyncBase, OrganizationIncludePalSettingMixin):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrganizationIncludePalSetting(CustomBase, OrganizationIncludePalSettingMixin):
|
|
32
|
+
pass
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
from datetime import date, datetime
|
|
3
3
|
|
|
4
|
-
from sqlalchemy import Computed, ForeignKey, func, literal, text
|
|
4
|
+
from sqlalchemy import Computed, ForeignKey, func, literal, text, ARRAY, String
|
|
5
5
|
from sqlalchemy.ext.hybrid import hybrid_property
|
|
6
6
|
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
|
7
7
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
@@ -43,6 +43,8 @@ class PlaylistMixin(TimeStampMixin):
|
|
|
43
43
|
published_at: Mapped[date | None] = mapped_column(nullable=True)
|
|
44
44
|
is_private: Mapped[bool] = mapped_column(default=False)
|
|
45
45
|
total_duration: Mapped[int] = mapped_column(default=0)
|
|
46
|
+
track_count: Mapped[int] = mapped_column(default=0)
|
|
47
|
+
artist_names: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[])
|
|
46
48
|
|
|
47
49
|
name_tsv = mapped_column(
|
|
48
50
|
TSVector(),
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"""Redis models."""
|
|
2
2
|
|
|
3
|
-
from .zone_state import
|
|
3
|
+
from .zone_state import (
|
|
4
|
+
ZoneState,
|
|
5
|
+
NowPlaying,
|
|
6
|
+
SessionId,
|
|
7
|
+
BucketTS,
|
|
8
|
+
PlaylistSimple,
|
|
9
|
+
PushedPlaylistDetails,
|
|
10
|
+
ScheduleDetails,
|
|
11
|
+
)
|
|
4
12
|
from .device import ActiveDevice
|
|
5
13
|
from .bucket import RedisTrackBucketItem
|
|
6
14
|
from .play_history import RedisTrackPlayHistoryItem
|
|
@@ -17,8 +25,11 @@ __all__ = [
|
|
|
17
25
|
"ZoneState",
|
|
18
26
|
"NowPlaying",
|
|
19
27
|
"SessionId",
|
|
20
|
-
"
|
|
28
|
+
"BucketTS",
|
|
21
29
|
"ActiveDevice",
|
|
30
|
+
"PlaylistSimple",
|
|
31
|
+
"PushedPlaylistDetails",
|
|
32
|
+
"ScheduleDetails",
|
|
22
33
|
"KEY_ZONE_PLAY_HISTORY_TEMPLATE",
|
|
23
34
|
"KEY_ZONE_PUSH_PLAYLIST_BUCKET_TEMPLATE",
|
|
24
35
|
"KEY_ZONE_SCHEDULE_BUCKET_TEMPLATE",
|
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
"""Redis track buckets"""
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
from pydantic import BaseModel, Field, ConfigDict
|
|
4
4
|
from typing import Annotated
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
import uuid
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
TrackId = Annotated[
|
|
10
|
-
uuid.UUID,
|
|
11
|
-
Field(description="Track ID (UUID)")
|
|
12
|
-
]
|
|
9
|
+
TrackId = Annotated[uuid.UUID, Field(description="Track ID (UUID)")]
|
|
13
10
|
|
|
14
|
-
PlaylistId = Annotated[
|
|
15
|
-
int,
|
|
16
|
-
Field(description="Playlist ID (integer)")
|
|
17
|
-
]
|
|
11
|
+
PlaylistId = Annotated[int, Field(description="Playlist ID (integer)")]
|
|
18
12
|
|
|
19
|
-
Timestamp = Annotated[
|
|
20
|
-
int,
|
|
21
|
-
Field(description="Unix timestamp")
|
|
22
|
-
]
|
|
13
|
+
Timestamp = Annotated[int, Field(description="Unix timestamp")]
|
|
23
14
|
|
|
24
15
|
|
|
25
16
|
class RedisTrackBucketItem(BaseModel):
|
|
@@ -34,10 +25,7 @@ class RedisTrackBucketItem(BaseModel):
|
|
|
34
25
|
playlist_id: PlaylistId
|
|
35
26
|
ts: Timestamp = Field(default_factory=lambda: int(datetime.now(timezone.utc).timestamp()))
|
|
36
27
|
|
|
37
|
-
model_config = ConfigDict(
|
|
38
|
-
json_encoders={uuid.UUID: str},
|
|
39
|
-
populate_by_name=True
|
|
40
|
-
)
|
|
28
|
+
model_config = ConfigDict(json_encoders={uuid.UUID: str}, populate_by_name=True)
|
|
41
29
|
|
|
42
30
|
def as_redis_entry(self) -> dict[str, int]:
|
|
43
31
|
"""
|
|
@@ -46,8 +34,7 @@ class RedisTrackBucketItem(BaseModel):
|
|
|
46
34
|
"""
|
|
47
35
|
key = self.model_dump_json(include={"track_id", "playlist_id"})
|
|
48
36
|
return {key: self.ts}
|
|
49
|
-
|
|
50
|
-
|
|
37
|
+
|
|
51
38
|
@classmethod
|
|
52
39
|
def from_redis_entry(cls, entry: dict[str, int]) -> "RedisTrackBucketItem":
|
|
53
40
|
"""
|
|
@@ -58,5 +45,6 @@ class RedisTrackBucketItem(BaseModel):
|
|
|
58
45
|
|
|
59
46
|
class RedisTrackBucketItemValue(BaseModel):
|
|
60
47
|
"""JSON string format for Redis keys: '{"track_id": "...", "playlist_id": ...}'"""
|
|
48
|
+
|
|
61
49
|
track_id: TrackId
|
|
62
50
|
playlist_id: PlaylistId
|
|
@@ -13,7 +13,7 @@ class ActiveDevice(BaseModel):
|
|
|
13
13
|
user_id: UUID
|
|
14
14
|
client_id: str
|
|
15
15
|
device_id: str
|
|
16
|
-
mode: Literal["player-mode", "controller-mode"]
|
|
16
|
+
mode: Literal["player-mode", "controller-mode", "force-player-mode"]
|
|
17
17
|
connected_at: datetime | str = Field(
|
|
18
18
|
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
19
19
|
)
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"""Redis keys."""
|
|
2
2
|
|
|
3
|
+
KEY_ZONE_ACTIVE_DEVICE_TEMPLATE = "zone_{zone_id}_active_devices"
|
|
3
4
|
KEY_ZONE_PLAY_HISTORY_TEMPLATE = "zone_{zone_id}_play_history"
|
|
4
5
|
KEY_ZONE_PUSH_PLAYLIST_BUCKET_TEMPLATE = "zone_{zone_id}_pp_bucket_{timeslot_ts}"
|
|
5
6
|
KEY_ZONE_SCHEDULE_BUCKET_TEMPLATE = "zone_{zone_id}_sc_bucket_{timeslot_ts}"
|
|
7
|
+
KEY_ZONE_SKIPPED_TRACKS_TEMPLATE = "zone_{zone_id}_skipped_tracks"
|
|
6
8
|
KEY_ZONE_STATE_TEMPLATE = "zone_{zone_id}_state"
|
|
7
|
-
KEY_ZONE_ACTIVE_DEVICE_TEMPLATE = "zone_{zone_id}_active_devices"
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
__all__ = [
|
|
12
|
+
"KEY_ZONE_ACTIVE_DEVICE_TEMPLATE",
|
|
11
13
|
"KEY_ZONE_PLAY_HISTORY_TEMPLATE",
|
|
12
14
|
"KEY_ZONE_PUSH_PLAYLIST_BUCKET_TEMPLATE",
|
|
13
15
|
"KEY_ZONE_SCHEDULE_BUCKET_TEMPLATE",
|
|
16
|
+
"KEY_ZONE_SKIPPED_TRACKS_TEMPLATE",
|
|
14
17
|
"KEY_ZONE_STATE_TEMPLATE",
|
|
15
|
-
"KEY_ZONE_ACTIVE_DEVICE_TEMPLATE",
|
|
16
18
|
]
|
|
@@ -5,15 +5,9 @@ from typing import Annotated
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
TrackId = Annotated[
|
|
9
|
-
str,
|
|
10
|
-
Field(description="Track ID (UUID or string)")
|
|
11
|
-
]
|
|
8
|
+
TrackId = Annotated[str, Field(description="Track ID (UUID or string)")]
|
|
12
9
|
|
|
13
|
-
Timestamp = Annotated[
|
|
14
|
-
int,
|
|
15
|
-
Field(description="Unix timestamp")
|
|
16
|
-
]
|
|
10
|
+
Timestamp = Annotated[int, Field(description="Unix timestamp")]
|
|
17
11
|
|
|
18
12
|
|
|
19
13
|
class RedisTrackPlayHistoryItem(BaseModel):
|
|
@@ -21,6 +15,7 @@ class RedisTrackPlayHistoryItem(BaseModel):
|
|
|
21
15
|
Represents the value stored in Redis sorted set:
|
|
22
16
|
A string like "track_id#timestamp"
|
|
23
17
|
"""
|
|
18
|
+
|
|
24
19
|
track_id: TrackId
|
|
25
20
|
ts: Timestamp = Field(default_factory=lambda: int(datetime.now(timezone.utc).timestamp()))
|
|
26
21
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Zone state schema."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NowPlaying(BaseModel):
|
|
10
|
+
"""Now playing schema."""
|
|
11
|
+
|
|
12
|
+
name: str = Field(description="The name of the track", default="UNKNOWN")
|
|
13
|
+
artist_name: str = Field(description="The artist name of the track", default="UNKNOWN")
|
|
14
|
+
album_name: str = Field(description="The album name of the track", default="UNKNOWN")
|
|
15
|
+
playlist_id: int | None = Field(description="The playlist id of the track", default=None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SessionId = Annotated[
|
|
19
|
+
int,
|
|
20
|
+
Field(
|
|
21
|
+
description="The session id of the pushed playlist. It's generated as a timestamp value of the current time."
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
BucketTS = Annotated[
|
|
25
|
+
int | None,
|
|
26
|
+
Field(
|
|
27
|
+
description="The bucket id of the pushed playlist. It's calculated as the timestamp value of the timeslot."
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PlaylistSimple(BaseModel):
|
|
33
|
+
"""Simple playlist schema."""
|
|
34
|
+
|
|
35
|
+
id: int = Field(
|
|
36
|
+
description="The id of the pushed playlist. It's generated as a timestamp value of the current time."
|
|
37
|
+
)
|
|
38
|
+
name: str = Field(
|
|
39
|
+
description="The name of the pushed playlist",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScheduleDetails(BaseModel):
|
|
44
|
+
"""Schedule details schema."""
|
|
45
|
+
|
|
46
|
+
start_at: datetime | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description="The date and time value of the schedule in local timezone.",
|
|
49
|
+
)
|
|
50
|
+
playlists: list[PlaylistSimple] | None = Field(
|
|
51
|
+
default=None,
|
|
52
|
+
description="The playlist of the schedule",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PushedPlaylistDetails(BaseModel):
|
|
57
|
+
"""Player details schema."""
|
|
58
|
+
|
|
59
|
+
bucket_key: str | None = Field(
|
|
60
|
+
description="""Pushed playlists does exist in Redis with a timestamp.
|
|
61
|
+
Example: zone_{zone_id}_pp_bucket_{ts}
|
|
62
|
+
""",
|
|
63
|
+
)
|
|
64
|
+
playlists: list[PlaylistSimple] | None = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description="The playlists of the pushed playlist",
|
|
67
|
+
)
|
|
68
|
+
expire_at: datetime | None = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description="The expire time of the pushed playlist",
|
|
71
|
+
)
|
|
72
|
+
expiry_type: Literal["infinite", "auto_expire"] | None = Field(
|
|
73
|
+
default=None,
|
|
74
|
+
description="The expiry type of the pushed playlist",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ZoneState(BaseModel):
|
|
79
|
+
"""Zone state schema."""
|
|
80
|
+
|
|
81
|
+
player_mode: Literal["scheduled", "pushplaylist"] = Field(
|
|
82
|
+
default="scheduled",
|
|
83
|
+
description="The mode of the player",
|
|
84
|
+
)
|
|
85
|
+
player_state: Literal["playing", "paused", "stopped", "ready"] = Field(
|
|
86
|
+
default="ready",
|
|
87
|
+
description="The state of the player",
|
|
88
|
+
)
|
|
89
|
+
now_playing: NowPlaying | None = Field(
|
|
90
|
+
default=None,
|
|
91
|
+
description="The currently playing track",
|
|
92
|
+
)
|
|
93
|
+
pp_details: PushedPlaylistDetails | None = Field(
|
|
94
|
+
default=None,
|
|
95
|
+
description="The details of the pushed playlist",
|
|
96
|
+
)
|
|
97
|
+
schedule_details: ScheduleDetails | None = Field(
|
|
98
|
+
default=None,
|
|
99
|
+
description="The details of the schedule",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
"ZoneState",
|
|
105
|
+
"NowPlaying",
|
|
106
|
+
"SessionId",
|
|
107
|
+
"BucketTS",
|
|
108
|
+
"PlaylistSimple",
|
|
109
|
+
"PushedPlaylistDetails",
|
|
110
|
+
"ScheduleDetails",
|
|
111
|
+
]
|
|
@@ -6,10 +6,11 @@ from typing import Literal
|
|
|
6
6
|
from uuid import UUID
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
from artemis_model.redis.zone_state import SessionId
|
|
10
9
|
from artemis_model.schedule import ScheduleItem
|
|
11
10
|
from pydantic import BaseModel, Field
|
|
12
11
|
|
|
12
|
+
from artemis_model.zone_activity import ZoneActivityType
|
|
13
|
+
|
|
13
14
|
|
|
14
15
|
class Action(str, Enum):
|
|
15
16
|
"""Message type enum."""
|
|
@@ -24,6 +25,8 @@ class Action(str, Enum):
|
|
|
24
25
|
RECALCULATE_SCHEDULE = "recalculate-schedule"
|
|
25
26
|
PLAYER_MODE_CHANGE = "player-mode-change"
|
|
26
27
|
BAN_TRACK = "ban-track"
|
|
28
|
+
ZONE_ACTIVITY = "zone-activity"
|
|
29
|
+
STOP_MUSIC = "stop-music"
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
class BaseMessage(BaseModel):
|
|
@@ -41,7 +44,15 @@ class MoveTimeSlotIn(BaseMessage):
|
|
|
41
44
|
|
|
42
45
|
action: Action = Action.MOVE_TIME_SLOT
|
|
43
46
|
zone_id: int
|
|
44
|
-
|
|
47
|
+
bucket_key: str
|
|
48
|
+
playlist_ids: list[int]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StopMusicIn(BaseMessage):
|
|
52
|
+
"""Stop music message schema."""
|
|
53
|
+
|
|
54
|
+
action: Action = Action.STOP_MUSIC
|
|
55
|
+
zone_id: int
|
|
45
56
|
|
|
46
57
|
|
|
47
58
|
class TriageScheduleIn(BaseMessage):
|
|
@@ -66,9 +77,6 @@ class PushPlaylistIn(BaseMessage):
|
|
|
66
77
|
expire_at: datetime | None = Field(
|
|
67
78
|
default=None, description="The datetime that the playlist will expire"
|
|
68
79
|
)
|
|
69
|
-
session_id: SessionId = Field(
|
|
70
|
-
default_factory=lambda: int(datetime.now(timezone.utc).timestamp()),
|
|
71
|
-
)
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
class PushPlaylistExpireIn(BaseMessage):
|
|
@@ -77,7 +85,7 @@ class PushPlaylistExpireIn(BaseMessage):
|
|
|
77
85
|
action: Action = Action.EXPIRE_PUSHED_PLAYLIST
|
|
78
86
|
zone_id: int
|
|
79
87
|
new_mode: Literal["pushplaylist", "scheduled"] = "scheduled"
|
|
80
|
-
|
|
88
|
+
bucket_key: str | None = None
|
|
81
89
|
|
|
82
90
|
|
|
83
91
|
class RefreshTrackBucketIn(BaseMessage):
|
|
@@ -85,6 +93,7 @@ class RefreshTrackBucketIn(BaseMessage):
|
|
|
85
93
|
|
|
86
94
|
action: Action = Action.REFRESH_TRACK_BUCKET
|
|
87
95
|
zone_id: int
|
|
96
|
+
bucket_key: str
|
|
88
97
|
|
|
89
98
|
|
|
90
99
|
class RecalculateScheduleIn(BaseMessage):
|
|
@@ -101,3 +110,13 @@ class BanTrackIn(BaseMessage):
|
|
|
101
110
|
zone_id: int
|
|
102
111
|
track_id: UUID
|
|
103
112
|
playlist_id: int
|
|
113
|
+
bucket_key: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ZoneActivityIn(BaseMessage):
|
|
117
|
+
"""Zone activity message schema."""
|
|
118
|
+
|
|
119
|
+
action: Action = Action.ZONE_ACTIVITY
|
|
120
|
+
zone_id: int
|
|
121
|
+
activity_type: ZoneActivityType
|
|
122
|
+
activity_data: dict
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Zone Activity Model"""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from sqlalchemy import Integer, String, JSON, Index
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
from artemis_model.base import AuditMixin, TimeStampMixin, CustomSyncBase, CustomBase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZoneActivityType(StrEnum):
|
|
10
|
+
"""Zone activity type"""
|
|
11
|
+
|
|
12
|
+
CONNECTED = "connected"
|
|
13
|
+
CONNECTION_MODE_CHANGED = "connection_mode_changed"
|
|
14
|
+
DISCONNECTED = "disconnected"
|
|
15
|
+
PLAYER_PAUSED = "player_paused"
|
|
16
|
+
PLAYER_RESUMED = "player_resumed"
|
|
17
|
+
TRACK_SKIPPED = "track_skipped"
|
|
18
|
+
TRACK_LIKED = "track_liked"
|
|
19
|
+
TRACK_BANNED = "track_banned"
|
|
20
|
+
TRACK_UNBANNED = "track_unbanned"
|
|
21
|
+
PLAYLIST_PUSHED = "playlist_pushed"
|
|
22
|
+
PLAYLIST_EXPIRED = "playlist_expired"
|
|
23
|
+
ACTIVE_PLAYLIST_CHANGED = "active_playlist_changed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlayerActivityMixin(TimeStampMixin, AuditMixin):
|
|
27
|
+
"""User activity log, sorted by created_at DESC for retrieval"""
|
|
28
|
+
|
|
29
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
30
|
+
player_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
31
|
+
activity_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
32
|
+
activity_data: Mapped[dict] = mapped_column(JSON, nullable=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Index("idx_player_activity_player_created", "player_id", PlayerActivityMixin.created_at.desc())
|
|
36
|
+
Index("idx_player_activity_created", PlayerActivityMixin.created_at.desc())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PlayerActivitySync(CustomSyncBase, PlayerActivityMixin):
|
|
40
|
+
"""Sync model for Player Activity"""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PlayerActivity(CustomBase, PlayerActivityMixin):
|
|
46
|
+
"""Base model for Player Activity"""
|
|
47
|
+
|
|
48
|
+
pass
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Zone state data models."""
|
|
2
|
+
|
|
3
|
+
# models/zone_state.py
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
|
10
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
11
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
12
|
+
from sqlalchemy.ext.declarative import declared_attr
|
|
13
|
+
|
|
14
|
+
from artemis_model.base import CustomSyncBase, TimeStampMixin, CustomBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ZoneStateMetaMixin(TimeStampMixin):
|
|
18
|
+
"""
|
|
19
|
+
Rarely changing part of a zone's state.
|
|
20
|
+
One row per zone.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
zone_id: Mapped[int] = mapped_column(
|
|
24
|
+
Integer,
|
|
25
|
+
ForeignKey("zone.id", ondelete="CASCADE"),
|
|
26
|
+
primary_key=True,
|
|
27
|
+
default=uuid.uuid4,
|
|
28
|
+
doc="Zone identifier (PK)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
player_mode: Mapped[str] = mapped_column(
|
|
32
|
+
String, default="scheduled", nullable=False, index=True
|
|
33
|
+
)
|
|
34
|
+
player_state: Mapped[str] = mapped_column(String, default="ready", nullable=False, index=True)
|
|
35
|
+
pp_details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
36
|
+
schedule_details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
37
|
+
|
|
38
|
+
@declared_attr
|
|
39
|
+
def now_playing(cls) -> Mapped["ZoneNowPlaying"]:
|
|
40
|
+
return relationship(
|
|
41
|
+
"ZoneNowPlaying",
|
|
42
|
+
back_populates="meta",
|
|
43
|
+
uselist=False,
|
|
44
|
+
cascade="all, delete-orphan",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ZoneStateMetaSync(CustomSyncBase, ZoneStateMetaMixin):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ZoneStateMeta(CustomBase, ZoneStateMetaMixin):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ZoneNowPlayingMixin:
|
|
57
|
+
"""
|
|
58
|
+
Frequently changing part of a zone's state.
|
|
59
|
+
Keep row narrow; PK-only index for cheap updates.
|
|
60
|
+
One row per zone (FK to ZoneStateMeta).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
zone_id: Mapped[int] = mapped_column(
|
|
64
|
+
Integer,
|
|
65
|
+
ForeignKey("zone_state_meta.zone_id", ondelete="CASCADE"),
|
|
66
|
+
primary_key=True,
|
|
67
|
+
doc="Matches zone_state_meta.zone_id",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
track_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
71
|
+
artist_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
72
|
+
album_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
|
73
|
+
playlist_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
74
|
+
|
|
75
|
+
# Lightweight timestamp for freshness; mirrors your style (see LoginHistory)
|
|
76
|
+
updated_at = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
|
77
|
+
|
|
78
|
+
@declared_attr
|
|
79
|
+
def meta(cls) -> Mapped["ZoneStateMeta"]:
|
|
80
|
+
return relationship("ZoneStateMeta", back_populates="now_playing")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ZoneNowPlayingSync(CustomSyncBase, ZoneNowPlayingMixin):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ZoneNowPlaying(CustomBase, ZoneNowPlayingMixin):
|
|
88
|
+
pass
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Zone state schema."""
|
|
2
|
-
|
|
3
|
-
from typing import Annotated, Literal
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel, Field, field_validator
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class NowPlaying(BaseModel):
|
|
9
|
-
"""Now playing schema."""
|
|
10
|
-
|
|
11
|
-
name: str = Field(description="The name of the track", default="UNKNOWN")
|
|
12
|
-
artist_name: str = Field(description="The artist name of the track", default="UNKNOWN")
|
|
13
|
-
album_name: str = Field(description="The album name of the track", default="UNKNOWN")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SessionId = Annotated[
|
|
17
|
-
int,
|
|
18
|
-
Field(
|
|
19
|
-
description="The session id of the pushed playlist. It's generated as a timestamp value of the current time."
|
|
20
|
-
),
|
|
21
|
-
]
|
|
22
|
-
BucketId = Annotated[
|
|
23
|
-
int | None,
|
|
24
|
-
Field(
|
|
25
|
-
description="The bucket id of the pushed playlist. It's calculated in set_bucket_track as the timestamp value of the timeslot."
|
|
26
|
-
),
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class ZoneState(BaseModel):
|
|
31
|
-
"""Zone state schema."""
|
|
32
|
-
|
|
33
|
-
player_mode: Literal["scheduled", "pushplaylist"] = Field(
|
|
34
|
-
default="scheduled",
|
|
35
|
-
description="The mode of the player",
|
|
36
|
-
)
|
|
37
|
-
player_state: Literal["playing", "paused", "stopped", "ready"] = Field(
|
|
38
|
-
default="ready",
|
|
39
|
-
description="The state of the player",
|
|
40
|
-
)
|
|
41
|
-
now_playing: NowPlaying | None = Field(
|
|
42
|
-
default=None,
|
|
43
|
-
description="The currently playing track",
|
|
44
|
-
)
|
|
45
|
-
push_playlist_details: dict[SessionId, BucketId] | None = Field(
|
|
46
|
-
default_factory=dict,
|
|
47
|
-
description="The details of the pushed playlist",
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
@field_validator("push_playlist_details", mode="before")
|
|
51
|
-
@classmethod
|
|
52
|
-
def default_empty_dict_if_none(cls, v):
|
|
53
|
-
"""Override none value to empty dict."""
|
|
54
|
-
return v or {}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
__all__ = ["ZoneState", "NowPlaying", "SessionId", "BucketId"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|