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.
Files changed (36) hide show
  1. {artemis_model-0.1.130 → artemis_model-0.1.182}/PKG-INFO +3 -2
  2. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/__init__.py +4 -0
  3. artemis_model-0.1.182/artemis_model/approved_playlist_list.py +55 -0
  4. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/auth.py +79 -1
  5. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/category.py +5 -12
  6. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/location.py +8 -0
  7. artemis_model-0.1.182/artemis_model/location_genre_exclusion.py +38 -0
  8. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/organization.py +21 -0
  9. artemis_model-0.1.182/artemis_model/organization_include_pal_setting.py +32 -0
  10. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/playlist.py +3 -1
  11. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/__init__.py +2 -2
  12. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/device.py +1 -1
  13. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/keys.py +4 -2
  14. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/zone_state.py +8 -15
  15. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/setting.py +1 -1
  16. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/sqs/messages.py +13 -6
  17. artemis_model-0.1.182/artemis_model/zone_state.py +88 -0
  18. {artemis_model-0.1.130 → artemis_model-0.1.182}/pyproject.toml +1 -1
  19. {artemis_model-0.1.130 → artemis_model-0.1.182}/README.md +0 -0
  20. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/album.py +0 -0
  21. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/artist.py +0 -0
  22. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/banned_tracks.py +0 -0
  23. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/base.py +0 -0
  24. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/dj_set.py +0 -0
  25. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/genre.py +0 -0
  26. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/message.py +0 -0
  27. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/otp.py +0 -0
  28. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/permission.py +0 -0
  29. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/bucket.py +0 -0
  30. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/redis/play_history.py +0 -0
  31. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/schedule.py +0 -0
  32. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/sqs/__init__.py +0 -0
  33. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/track.py +0 -0
  34. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/user.py +0 -0
  35. {artemis_model-0.1.130 → artemis_model-0.1.182}/artemis_model/zone.py +0 -0
  36. {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.3
1
+ Metadata-Version: 2.4
2
2
  Name: artemis-model
3
- Version: 0.1.130
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 ARRAY, Computed, String, func, literal, text
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
- name: Mapped[str] = mapped_column(nullable=False)
20
- sub_categories: Mapped[list[str]] = mapped_column(ARRAY(String), default=[])
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', name)", persisted=True),
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("name"),
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
- BucketId,
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
- "BucketId",
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, field_validator
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
- BucketId = Annotated[
24
+ BucketTS = Annotated[
24
25
  int | None,
25
26
  Field(
26
- description="The bucket id of the pushed playlist. It's calculated in set_bucket_track as the timestamp value of the timeslot."
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
- start_ts_utc: int | None = Field(
46
+ start_at: datetime | None = Field(
46
47
  default=None,
47
- description="The start timestamp of the schedule in UTC",
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
- redis_details: dict[SessionId, BucketId] | None = Field(
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
- "BucketId",
107
+ "BucketTS",
115
108
  "PlaylistSimple",
116
109
  "PushedPlaylistDetails",
117
110
  "ScheduleDetails",
@@ -25,7 +25,7 @@ class SettingMixin(TimeStampMixin):
25
25
  "<track_id>": "<track_name>",
26
26
  ...
27
27
  },
28
- "exclude_pal": True/False
28
+ "include_pal": True/False
29
29
  }
30
30
  """
31
31
 
@@ -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
- timeslot: str
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
- session_id: SessionId | None = None
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "artemis-model"
3
- version = "0.1.130"
3
+ version = "0.1.182"
4
4
  description = ""
5
5
  authors = ["Jukeboxy"]
6
6
  readme = "README.md"