artemis-model 0.1.130__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.130 → artemis_model-0.1.182}/PKG-INFO +3 -2
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/__init__.py +4 -0
- artemis_model-0.1.182/artemis_model/approved_playlist_list.py +55 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/auth.py +79 -1
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/category.py +5 -12
- {artemis_model-0.1.130 → 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.130 → 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.130 → artemis_model-0.1.182}/artemis_model/playlist.py +3 -1
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/__init__.py +2 -2
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/device.py +1 -1
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/keys.py +4 -2
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/zone_state.py +8 -15
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/setting.py +1 -1
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/sqs/messages.py +13 -6
- artemis_model-0.1.182/artemis_model/zone_state.py +88 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/pyproject.toml +1 -1
- {artemis_model-0.1.130 → artemis_model-0.1.182}/README.md +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/album.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/artist.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/banned_tracks.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/base.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/dj_set.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/genre.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/message.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/otp.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/permission.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/bucket.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/play_history.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/schedule.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/sqs/__init__.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/track.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/user.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/zone.py +0 -0
- {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/zone_activity.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
|
|
@@ -16,3 +19,4 @@ from artemis_model.zone import * # noqa
|
|
|
16
19
|
from artemis_model.otp import * # noqa
|
|
17
20
|
from artemis_model.banned_tracks import * # noqa
|
|
18
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
|
|
@@ -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(),
|
|
@@ -4,7 +4,7 @@ from .zone_state import (
|
|
|
4
4
|
ZoneState,
|
|
5
5
|
NowPlaying,
|
|
6
6
|
SessionId,
|
|
7
|
-
|
|
7
|
+
BucketTS,
|
|
8
8
|
PlaylistSimple,
|
|
9
9
|
PushedPlaylistDetails,
|
|
10
10
|
ScheduleDetails,
|
|
@@ -25,7 +25,7 @@ __all__ = [
|
|
|
25
25
|
"ZoneState",
|
|
26
26
|
"NowPlaying",
|
|
27
27
|
"SessionId",
|
|
28
|
-
"
|
|
28
|
+
"BucketTS",
|
|
29
29
|
"ActiveDevice",
|
|
30
30
|
"PlaylistSimple",
|
|
31
31
|
"PushedPlaylistDetails",
|
|
@@ -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
|
]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from typing import Annotated, Literal
|
|
5
5
|
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class NowPlaying(BaseModel):
|
|
@@ -12,6 +12,7 @@ class NowPlaying(BaseModel):
|
|
|
12
12
|
name: str = Field(description="The name of the track", default="UNKNOWN")
|
|
13
13
|
artist_name: str = Field(description="The artist name of the track", default="UNKNOWN")
|
|
14
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)
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
SessionId = Annotated[
|
|
@@ -20,10 +21,10 @@ SessionId = Annotated[
|
|
|
20
21
|
description="The session id of the pushed playlist. It's generated as a timestamp value of the current time."
|
|
21
22
|
),
|
|
22
23
|
]
|
|
23
|
-
|
|
24
|
+
BucketTS = Annotated[
|
|
24
25
|
int | None,
|
|
25
26
|
Field(
|
|
26
|
-
description="The bucket id of the pushed playlist. It's calculated
|
|
27
|
+
description="The bucket id of the pushed playlist. It's calculated as the timestamp value of the timeslot."
|
|
27
28
|
),
|
|
28
29
|
]
|
|
29
30
|
|
|
@@ -42,9 +43,9 @@ class PlaylistSimple(BaseModel):
|
|
|
42
43
|
class ScheduleDetails(BaseModel):
|
|
43
44
|
"""Schedule details schema."""
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
start_at: datetime | None = Field(
|
|
46
47
|
default=None,
|
|
47
|
-
description="The
|
|
48
|
+
description="The date and time value of the schedule in local timezone.",
|
|
48
49
|
)
|
|
49
50
|
playlists: list[PlaylistSimple] | None = Field(
|
|
50
51
|
default=None,
|
|
@@ -55,11 +56,9 @@ class ScheduleDetails(BaseModel):
|
|
|
55
56
|
class PushedPlaylistDetails(BaseModel):
|
|
56
57
|
"""Player details schema."""
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
default_factory=dict,
|
|
59
|
+
bucket_key: str | None = Field(
|
|
60
60
|
description="""Pushed playlists does exist in Redis with a timestamp.
|
|
61
61
|
Example: zone_{zone_id}_pp_bucket_{ts}
|
|
62
|
-
Every pushplaylist action has a session id. Here we map these session ids to the bucket id.
|
|
63
62
|
""",
|
|
64
63
|
)
|
|
65
64
|
playlists: list[PlaylistSimple] | None = Field(
|
|
@@ -75,12 +74,6 @@ class PushedPlaylistDetails(BaseModel):
|
|
|
75
74
|
description="The expiry type of the pushed playlist",
|
|
76
75
|
)
|
|
77
76
|
|
|
78
|
-
@field_validator("redis_details", mode="before")
|
|
79
|
-
@classmethod
|
|
80
|
-
def default_empty_dict_if_none(cls, v):
|
|
81
|
-
"""Override none value to empty dict."""
|
|
82
|
-
return v or {}
|
|
83
|
-
|
|
84
77
|
|
|
85
78
|
class ZoneState(BaseModel):
|
|
86
79
|
"""Zone state schema."""
|
|
@@ -111,7 +104,7 @@ __all__ = [
|
|
|
111
104
|
"ZoneState",
|
|
112
105
|
"NowPlaying",
|
|
113
106
|
"SessionId",
|
|
114
|
-
"
|
|
107
|
+
"BucketTS",
|
|
115
108
|
"PlaylistSimple",
|
|
116
109
|
"PushedPlaylistDetails",
|
|
117
110
|
"ScheduleDetails",
|
|
@@ -6,7 +6,6 @@ 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
|
|
|
@@ -27,6 +26,7 @@ class Action(str, Enum):
|
|
|
27
26
|
PLAYER_MODE_CHANGE = "player-mode-change"
|
|
28
27
|
BAN_TRACK = "ban-track"
|
|
29
28
|
ZONE_ACTIVITY = "zone-activity"
|
|
29
|
+
STOP_MUSIC = "stop-music"
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
class BaseMessage(BaseModel):
|
|
@@ -44,7 +44,15 @@ class MoveTimeSlotIn(BaseMessage):
|
|
|
44
44
|
|
|
45
45
|
action: Action = Action.MOVE_TIME_SLOT
|
|
46
46
|
zone_id: int
|
|
47
|
-
|
|
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
|
|
48
56
|
|
|
49
57
|
|
|
50
58
|
class TriageScheduleIn(BaseMessage):
|
|
@@ -69,9 +77,6 @@ class PushPlaylistIn(BaseMessage):
|
|
|
69
77
|
expire_at: datetime | None = Field(
|
|
70
78
|
default=None, description="The datetime that the playlist will expire"
|
|
71
79
|
)
|
|
72
|
-
session_id: SessionId = Field(
|
|
73
|
-
default_factory=lambda: int(datetime.now(timezone.utc).timestamp()),
|
|
74
|
-
)
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
class PushPlaylistExpireIn(BaseMessage):
|
|
@@ -80,7 +85,7 @@ class PushPlaylistExpireIn(BaseMessage):
|
|
|
80
85
|
action: Action = Action.EXPIRE_PUSHED_PLAYLIST
|
|
81
86
|
zone_id: int
|
|
82
87
|
new_mode: Literal["pushplaylist", "scheduled"] = "scheduled"
|
|
83
|
-
|
|
88
|
+
bucket_key: str | None = None
|
|
84
89
|
|
|
85
90
|
|
|
86
91
|
class RefreshTrackBucketIn(BaseMessage):
|
|
@@ -88,6 +93,7 @@ class RefreshTrackBucketIn(BaseMessage):
|
|
|
88
93
|
|
|
89
94
|
action: Action = Action.REFRESH_TRACK_BUCKET
|
|
90
95
|
zone_id: int
|
|
96
|
+
bucket_key: str
|
|
91
97
|
|
|
92
98
|
|
|
93
99
|
class RecalculateScheduleIn(BaseMessage):
|
|
@@ -104,6 +110,7 @@ class BanTrackIn(BaseMessage):
|
|
|
104
110
|
zone_id: int
|
|
105
111
|
track_id: UUID
|
|
106
112
|
playlist_id: int
|
|
113
|
+
bucket_key: str
|
|
107
114
|
|
|
108
115
|
|
|
109
116
|
class ZoneActivityIn(BaseMessage):
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|