Compare commits
3 Commits
playlist-1
...
grok-assis
| Author | SHA1 | Date | |
|---|---|---|---|
|
3b1be1c4b1
|
|||
|
8f709877bc
|
|||
|
57e051b45c
|
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"_checkout": "2022.6.3",
|
||||
"_output_dir": "/config/workspace/DarkHelm.org/plex-playlist",
|
||||
"_repo_dir": "/config/.cookiecutters/cookiecutter-hypermodern-python",
|
||||
"_output_dir": "/home/darkhelm/Projects/DarkHelm.org/plex-playlist/temp",
|
||||
"_template": "gh:cjolowicz/cookiecutter-hypermodern-python",
|
||||
"author": "Cliff Hill",
|
||||
"copyright_year": "2023",
|
||||
"development_status": "Development Status :: 1 - Planning",
|
||||
"copyright_year": "2025",
|
||||
"development_status": "Development Status :: 2 - Pre-Alpha",
|
||||
"email": "xlorep@darkhelm.org",
|
||||
"friendly_name": "Plex Playlist",
|
||||
"github_user": "xlorepdarkhelm",
|
||||
"license": "MIT",
|
||||
"license": "Apache-2.0",
|
||||
"package_name": "playlist",
|
||||
"project_name": "plex-playlist",
|
||||
"version": "0.0.0"
|
||||
|
||||
4
.flake8
4
.flake8
@@ -1,9 +1,9 @@
|
||||
[flake8]
|
||||
select = B,B9,C,D,DAR,E,F,N,RST,S,W
|
||||
ignore = E203,E501,RST201,RST203,RST301,W503
|
||||
max-line-length = 100
|
||||
max-line-length = 80
|
||||
max-complexity = 10
|
||||
docstring-convention = google
|
||||
per-file-ignores = backend/tests/*:S101
|
||||
per-file-ignores = tests/*:S101
|
||||
rst-roles = class,const,func,meth,mod,ref
|
||||
rst-directives = deprecated
|
||||
|
||||
20
.github/docs/adrs/000-template.md
vendored
20
.github/docs/adrs/000-template.md
vendored
@@ -1,20 +0,0 @@
|
||||
# ADR N: brief decision title
|
||||
|
||||
Describe here the forces that influence the design decision, including technological, cost-related, and project local.
|
||||
|
||||
## Decision
|
||||
|
||||
Describe here our response to these forces, that is, the design decision that was made. State the decision in full sentences, with active voice ("We will...").
|
||||
|
||||
## Rationale
|
||||
|
||||
Describe here the rationale for the design decision. Also indicate the rationale for significant _rejected_ alternatives. This section may also indicate assumptions, constraints, requirements, and results of evaluations and experiments.
|
||||
|
||||
## Status
|
||||
|
||||
[Proposed | Accepted | Deprecated | Superseded]
|
||||
If deprecated, indicate why. If superseded, include a link to the new ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
Describe here the resulting context, after applying the decision. All consequences should be listed, not just the "positive" ones.
|
||||
10
.github/docs/diagrams/updater.mermaid
vendored
10
.github/docs/diagrams/updater.mermaid
vendored
@@ -1,10 +0,0 @@
|
||||
flowchart TD
|
||||
A[Start] --> B[[Update start timestamp & get last updated]]
|
||||
B --> C([Get track from plex])
|
||||
C --> |more tracks| D[[Remove recently played tracks from playlist]]
|
||||
D --> E[[Insert/update track into database]]
|
||||
E --> C
|
||||
C --> |no more tracks| F[[Clean up genres]]
|
||||
F --> G[[Calculate statistics]]
|
||||
G --> H[[Update end timestamp]]
|
||||
H --> I[End]
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,3 @@
|
||||
/docs/_build/
|
||||
/src/*.egg-info/
|
||||
__pycache__/
|
||||
.env
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Contributor Guide
|
||||
|
||||
Thank you for your interest in improving this project.
|
||||
This project is open-source under the [MIT license] and
|
||||
This project is open-source under the [Apache 2.0 license] and
|
||||
welcomes contributions in the form of bug reports, feature requests, and pull requests.
|
||||
|
||||
Here is a list of important resources for contributors:
|
||||
@@ -11,7 +11,7 @@ Here is a list of important resources for contributors:
|
||||
- [Issue Tracker]
|
||||
- [Code of Conduct]
|
||||
|
||||
[mit license]: https://opensource.org/licenses/MIT
|
||||
[apache 2.0 license]: https://opensource.org/licenses/Apache-2.0
|
||||
[source code]: https://github.com/xlorepdarkhelm/plex-playlist
|
||||
[documentation]: https://plex-playlist.readthedocs.io/
|
||||
[issue tracker]: https://github.com/xlorepdarkhelm/plex-playlist/issues
|
||||
|
||||
230
LICENSE
230
LICENSE
@@ -1,21 +1,217 @@
|
||||
MIT License
|
||||
Copyright 2025 Cliff Hill
|
||||
|
||||
Copyright © 2023 Cliff Hill
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
@@ -48,7 +48,7 @@ To learn more, see the [Contributor Guide].
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the terms of the [MIT license][license],
|
||||
Distributed under the terms of the [Apache 2.0 license][license],
|
||||
_Plex Playlist_ is free and open source software.
|
||||
|
||||
## Issues
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
```{include} ../../CODE_OF_CONDUCT.md
|
||||
|
||||
```
|
||||
@@ -1,53 +0,0 @@
|
||||
"""All enums for the project are kept here."""
|
||||
|
||||
from enum import StrEnum
|
||||
from enum import auto
|
||||
from typing import Generator
|
||||
|
||||
|
||||
class Sublist(StrEnum):
|
||||
"""StrEnum to categorize tracks within a playlist based on various criteria.
|
||||
|
||||
Values are auto-generated.
|
||||
|
||||
Attributes:
|
||||
LEAST_RECENTLY_ADDED: Represents tracks that were added to the library the earliest.
|
||||
LEAST_RECENTLY_PLAYED: Represents tracks that were last played the longest time ago.
|
||||
LEAST_OFTEN_PLAYED: Represents tracks that have been played the least often.
|
||||
RANDOM: Represents tracks that are selected randomly.
|
||||
"""
|
||||
|
||||
LEAST_RECENTLY_ADDED = auto()
|
||||
LEAST_RECENTLY_PLAYED = auto()
|
||||
LEAST_OFTEN_PLAYED = auto()
|
||||
RANDOM = auto()
|
||||
|
||||
def standard(self) -> Generator["Sublist", None, None]:
|
||||
"""Generate the standard sublist types one at a time."""
|
||||
yield self.LEAST_RECENTLY_ADDED
|
||||
yield self.LEAST_RECENTLY_PLAYED
|
||||
yield self.LEAST_OFTEN_PLAYED
|
||||
|
||||
|
||||
class TimedEvent(StrEnum):
|
||||
"""Enumerates the start/end for timed processes like updater.
|
||||
|
||||
Attributes:
|
||||
START: Represents the start of the event.
|
||||
END: Represents the end of the event.
|
||||
"""
|
||||
|
||||
START = auto()
|
||||
END = auto()
|
||||
|
||||
|
||||
class Event(StrEnum):
|
||||
"""Enumerates the major backend events that are run.
|
||||
|
||||
Attributes:
|
||||
UPDATER: Represents the track updater process.
|
||||
GENERATOR: Represents the playlist generator process.
|
||||
"""
|
||||
|
||||
UPDATER: auto()
|
||||
GENERATOR: auto()
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Environment variables used throughout the application."""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
load_dotenv() # Take environment variables from .env.
|
||||
|
||||
PLEX_SERVER_URL = os.getenv("PLEX_SERVER_URL", "http://default-plex-server-url")
|
||||
PLEX_TOKEN = os.getenv("PLEX_TOKEN", "default-plex-token")
|
||||
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL", "postgres://user:password@localhost/database"
|
||||
) # Default fallback
|
||||
|
||||
MAX_TRACKS_QUERIED = int(
|
||||
os.getenv("MAX_TRACKS_QUERIED", 1000)
|
||||
) # Default to 1000 if not set
|
||||
|
||||
MAX_PLAYTIME = (
|
||||
int(os.getenv("MAX_PLAYTIME", 16)) * 60 * 60
|
||||
) # In seconds, but env var is in hours.
|
||||
|
||||
PODCAST_DAYS = os.getenv("PODCAST_DAYS")
|
||||
|
||||
SSH_USER = os.getenv("SSH_USER")
|
||||
SSH_SERVER = os.getenv("SSH_SERVER")
|
||||
SSH_PATH = os.getenv("SSH_PATH")
|
||||
SSH_KEY_PATH = os.getenv("SSH_KEY_PATH")
|
||||
@@ -1,405 +0,0 @@
|
||||
"""All database models are defined here."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from playlist import enums
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import Enum
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy import UniqueConstraint
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlalchemy.orm import mapped_column
|
||||
from sqlalchemy.orm import registry
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
|
||||
# Registry initialization using SQLAlchemy's new ORM 2.0 style
|
||||
mapper_registry = registry()
|
||||
|
||||
holiday_track_association = Table(
|
||||
"holiday_track_association",
|
||||
mapper_registry.metadata,
|
||||
Column("holiday_id", ForeignKey("holidays.id"), primary_key=True),
|
||||
Column("track_id", ForeignKey("tracks.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class User:
|
||||
"""Represents a system user, capable of owning a single playlist.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the user.
|
||||
username (str): Unique username for the user.
|
||||
|
||||
Relationships:
|
||||
playlist (Playlist): The playlist owned by the user, one-to-one relationship.
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
playlist: Mapped["Playlist"] = relationship(
|
||||
"Playlist", back_populates="owner", uselist=False
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Playlist:
|
||||
"""Represents a playlist containing multiple unique tracks, owned by a user.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the playlist.
|
||||
name (str): Name of the playlist.
|
||||
owner (User): The user who owns the playlist, one-to-one relationship.
|
||||
|
||||
Relationships:
|
||||
tracks (list[Track]): List of tracks in the playlist, many-to-many relationship.
|
||||
"""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
owner: Mapped[User] = relationship("User", back_populates="playlist")
|
||||
playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack", back_populates="playlist"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class PlaylistTrack:
|
||||
"""Represents an entry in a playlist that associates a track with additional context.
|
||||
|
||||
Each playlist track is tied to exactly one track and optionally linked to a category,
|
||||
a holiday, or a podcast episode. The playlist track can also categorize the track
|
||||
as part of a specific sublist based on how the track was added to the playlist.
|
||||
|
||||
Attributes:
|
||||
id (int): The primary key for the playlist track.
|
||||
track_id (int): The foreign key linked to the track's primary key.
|
||||
category_id (int, optional): The foreign key linked to the category's primary key, nullable.
|
||||
holiday_id (int, optional): The foreign key linked to the holiday's primary key, nullable.
|
||||
episode_id (int, optional): The foreign key linked to the podcast episode's primary
|
||||
key, nullable.
|
||||
favorite (bool, optional): Flag to indicate if the track is marked as a favorite, nullable.
|
||||
sublist (SublistType, optional): The type of sublist the track belongs to, nullable.
|
||||
|
||||
Relationships:
|
||||
track (Track): The track associated with this playlist track.
|
||||
category (Category, optional): The category associated with this playlist track, nullable.
|
||||
holiday (Holiday, optional): The holiday associated with this playlist track, nullable.
|
||||
episode (Episode, optional): The podcast episode associated with this playlist
|
||||
track, nullable.
|
||||
|
||||
Constraints:
|
||||
At least one of `category_id`, `holiday_id`, or `episode_id` must be non-null.
|
||||
Fields `favorite` and `sublist` must be null if `episode_id` is not null.
|
||||
"""
|
||||
|
||||
__tablename__ = "playlist_tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id"), nullable=False)
|
||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
holiday_id: Mapped[int] = mapped_column(ForeignKey("holidays.id"), nullable=True)
|
||||
episode_id: Mapped[int] = mapped_column(ForeignKey("episodes.id"), nullable=True)
|
||||
favorite: Mapped[bool] = mapped_column(Boolean, nullable=True)
|
||||
sublist: Mapped[str] = mapped_column(Enum(enums.Sublist), nullable=True)
|
||||
|
||||
playlist: Mapped["Playlist"] = relationship("Playlist")
|
||||
track: Mapped["Track"] = relationship("Track")
|
||||
category: Mapped["Category"] = relationship("Category")
|
||||
holiday: Mapped["Holiday"] = relationship("Holiday")
|
||||
episode: Mapped["Episode"] = relationship("Episode")
|
||||
|
||||
__table_args__ = (
|
||||
# Check constraint to ensure one of the category, holiday, or episode must be not null
|
||||
{
|
||||
"sqlite_with_rowid": False,
|
||||
"postgresql_where": (
|
||||
(
|
||||
category_id.is_not(None)
|
||||
| holiday_id.is_not(None)
|
||||
| episode_id.is_not(None)
|
||||
)
|
||||
& (episode_id.is_not(None) & favorite.is_(None) & sublist.is_(None))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Track:
|
||||
"""Represents a musical track with comprehensive metadata.
|
||||
|
||||
Attributes:
|
||||
id (int): Unique identifier for the track.
|
||||
title (str): Title of the track.
|
||||
artist (str): Artist of the track.
|
||||
album (str): Album name.
|
||||
cover_image (str): URL or path to the album cover image.
|
||||
duration (int): Duration of the track in seconds.
|
||||
times_played (int): Number of times the track has been played.
|
||||
date_last_played (DateTime): Date the track was last played.
|
||||
date_added (DateTime): Date the track was added to the database.
|
||||
rating (float): Rating of the track on a 1-5 scale.
|
||||
genre_id (int): ForeignKey linking to the genre.
|
||||
category_id (int): Foreign key linking to the category.
|
||||
server_id (str): Identifier for the server where the track is hosted, indexed.
|
||||
|
||||
Relationships:
|
||||
genre (Genre): The genre for this track.
|
||||
playlist_tracks (list[PlaylistTrack]): List of playlist tracks for this track.
|
||||
category (Category): The category for this track.
|
||||
holidays (list[Holiday]): The list of holidays this track is assigned to.
|
||||
podcast_episode (Episode | None): The optional podcast episode this track is linked to.
|
||||
"""
|
||||
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
artist: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
album: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
cover_image: Mapped[str] = mapped_column(String(255))
|
||||
duration: Mapped[int] = mapped_column(Integer)
|
||||
times_played: Mapped[int] = mapped_column(Integer, default=0)
|
||||
date_last_played: Mapped[DateTime] = mapped_column(DateTime)
|
||||
date_added: Mapped[DateTime] = mapped_column(DateTime, default=func.now())
|
||||
rating: Mapped[float] = mapped_column(Float)
|
||||
genre_id: Mapped[int] = mapped_column(ForeignKey("genres.id"))
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
server_id: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True, unique=True
|
||||
)
|
||||
|
||||
genre: Mapped["Genre"] = relationship("Genre", back_populates="tracks")
|
||||
category: Mapped["Category"] = relationship("Category", back_populates="tracks")
|
||||
holidays: Mapped[list["Holiday"]] = relationship(
|
||||
"Holiday", secondary=holiday_track_association, back_populates="tracks"
|
||||
)
|
||||
playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack", back_populates="track"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Genre:
|
||||
"""Represents a music genre.
|
||||
|
||||
Each genre can be associated with multiple tracks and belongs to exactly one category.
|
||||
|
||||
Attributes:
|
||||
id (int): Primary key.
|
||||
name (str): Name of the genre, which is unique within the same category.
|
||||
category_id (int): Foreign key linking to the category the genre belongs to.
|
||||
category (Category): The category this genre is part of.
|
||||
|
||||
Relationships:
|
||||
tracks (list[Track]): List of tracks associated with this genre.
|
||||
"""
|
||||
|
||||
__tablename__ = "genres"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
|
||||
category: Mapped["Category"] = relationship("Category", back_populates="genres")
|
||||
tracks: Mapped[list["Track"]] = relationship("Track", back_populates="genre")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("name", "category_id", name="uix_genre_name_category_id"),
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Category:
|
||||
"""Represents a category of music that can encompass multiple genres.
|
||||
|
||||
Each category can include various genres, providing a way to classify tracks into different
|
||||
musical styles or themes.
|
||||
|
||||
Attributes:
|
||||
id (int): Primary key.
|
||||
name (str): Unique name of the category.
|
||||
|
||||
Relationships:
|
||||
genres (list[Genre]): List of genres associated with this category.
|
||||
"""
|
||||
|
||||
__tablename__ = "categories"
|
||||
__sa_dataclass_metadata_key__ = "sa"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
genres: Mapped[list["Genre"]] = relationship("Genre", back_populates="category")
|
||||
tracks: Mapped[list["Track"]] = relationship("Track", back_populates="category")
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Holiday:
|
||||
"""Represents a holiday, which can be linked to specific tracks for special occasions.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The name of the holiday.
|
||||
start_date (Mapped[DateTime]): The starting date of the holiday.
|
||||
end_date (Mapped[DateTime]): The ending date of the holiday.
|
||||
is_active (property): A calculated property that checks if the holiday is currently active.
|
||||
|
||||
Relationships:
|
||||
tracks (Mapped[list[Track]]): List of tracks associated with this holiday.
|
||||
"""
|
||||
|
||||
__tablename__ = "holidays"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
start_date: Mapped[DateTime] = mapped_column(DateTime, nullable=False)
|
||||
end_date: Mapped[DateTime] = mapped_column(DateTime, nullable=False)
|
||||
tracks: Mapped[list[Track]] = relationship(
|
||||
"Track", secondary=holiday_track_association, back_populates="holidays"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Calculate whether the holiday is currently active based on the system's current date.
|
||||
|
||||
This does not use the database but computes based on the current datetime.
|
||||
"""
|
||||
return self.start_date <= datetime.now() <= self.end_date
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Podcast:
|
||||
"""Represents a podcast, which consists of multiple episodes.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The name of the podcast.
|
||||
feed_url (Mapped[str]): The URL of the podcast's RSS feed.
|
||||
|
||||
Relationships:
|
||||
episodes (Mapped[list[Episode]]): List of episodes associated with this podcast.
|
||||
"""
|
||||
|
||||
__tablename__ = "podcasts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
feed_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
episodes: Mapped[list["Episode"]] = relationship(
|
||||
"Episode", back_populates="podcast"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Episode:
|
||||
"""Represents an episode of a podcast.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
name (Mapped[str]): The title of the episode.
|
||||
filename (Mapped[str]): The filename of the episode's media file.
|
||||
podcast_id (Mapped[int]): Foreign key linking to the associated podcast.
|
||||
server_id (Mapped[str | None]): Identifier for the server where the episode is hosted, indexed.
|
||||
date_published (Mapped[datetime]): The publication date of the episode in the podcast feed.
|
||||
track_id (Mapped[int | None]): Optional foreign key linking to the associated track.
|
||||
|
||||
Relationships:
|
||||
podcast (Mapped[Podcast]): The podcast this episode belongs to.
|
||||
track (Mapped[Track | None]): Optional relation to the track from the plex server.
|
||||
"""
|
||||
|
||||
__tablename__ = "episodes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
podcast_id: Mapped[int] = mapped_column(ForeignKey("podcasts.id"))
|
||||
server_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True, unique=True
|
||||
)
|
||||
date_published: Mapped[datetime] = mapped_column(datetime, nullable=False)
|
||||
track_id: Mapped[int | None] = mapped_column(ForeignKey("tracks.id"), nullable=True)
|
||||
|
||||
podcast: Mapped[Podcast] = relationship("Podcast", back_populates="episodes")
|
||||
track: Mapped[Track | None] = relationship(
|
||||
"Track", back_populates="podcast_episode"
|
||||
)
|
||||
|
||||
|
||||
@mapper_registry.mapped_as_dataclass
|
||||
class Statistics:
|
||||
"""Stores various computed statistics and operational metrics for the application.
|
||||
|
||||
Attributes:
|
||||
id (Mapped[int]): The primary key.
|
||||
average_track_length (Mapped[float]): The average length of tracks in seconds.
|
||||
total_podcast_length (Mapped[float]): The total length of all podcast episodes in seconds.
|
||||
max_playtime (Mapped[float]): The maximum playtime after accounting for podcasts, in seconds.
|
||||
max_tracks_per_day (Mapped[int]): The maximum number of tracks that can be played in a day.
|
||||
active_holiday_count (Mapped[int]): The number of active holidays affecting today's playlist.
|
||||
category_count (Mapped[int]): The number of categories affecting today's playlist.
|
||||
max_holiday_tracks (Mapped[int]): The maximum number of tracks that can be allocated to holidays.
|
||||
max_regular_tracks (Mapped[int]): The maximum number of regular tracks per day.
|
||||
max_regular_favorite_tracks (Mapped[int]): The maximum number of favorite tracks per day.
|
||||
max_holiday_favorite_tracks (Mapped[int]): The maximum number of favorite tracks that can be
|
||||
allocated to holidays.
|
||||
max_regular_general_tracks (Mapped[int]): The maximum number of general tracks per day.
|
||||
max_holiday_general_tracks (Mapped[int]): The maximum number of general tracks that can be
|
||||
allocated to holidays.
|
||||
max_regular_favorite_base (Mapped[int]): The maximum number of favorite tracks in one sublist.
|
||||
max_regular_general_base (Mapped[int]): The maximum number of general tracks in one sublist.
|
||||
max_holiday_favorite_base (Mapped[int]): The maximum number of favorite tracks in one sublist
|
||||
for holidays.
|
||||
max_holiday_general_base (Mapped[int]): The maximum number of general tracks in one sublist for
|
||||
holidays.
|
||||
start_update (Mapped[DateTime]): The timestamp when track updates begin.
|
||||
end_update (Mapped[DateTime]): The timestamp when track updates end.
|
||||
start_playlist_gen (Mapped[DateTime]): The timestamp when the playlist generation begins.
|
||||
end_playlist_gen (Mapped[DateTime]): The timestamp when the playlist generation ends.
|
||||
"""
|
||||
|
||||
__tablename__ = "statistics"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
average_track_length: Mapped[float] = mapped_column(Float)
|
||||
total_podcast_length: Mapped[float] = mapped_column(Float)
|
||||
max_playtime: Mapped[float] = mapped_column(Float)
|
||||
max_tracks_per_day: Mapped[int] = mapped_column(Integer)
|
||||
active_holiday_count: Mapped[int] = mapped_column(Integer)
|
||||
category_count: Mapped[int] = mapped_column(Integer)
|
||||
max_holiday_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_regular_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_regular_favorite_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_regular_general_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_holiday_favorite_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_holiday_general_tracks: Mapped[int] = mapped_column(Integer)
|
||||
max_regular_favorite_base: Mapped[int] = mapped_column(Integer)
|
||||
max_regular_general_base: Mapped[int] = mapped_column(Integer)
|
||||
max_holiday_favorite_base: Mapped[int] = mapped_column(Integer)
|
||||
max_holiday_general_base: Mapped[int] = mapped_column(Integer)
|
||||
start_update: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
end_update: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
start_playlist_gen: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
end_playlist_gen: Mapped[DateTime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=None
|
||||
)
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Contains the functions that are used to generate the playlist."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiologger import Logger
|
||||
from playlist import enums
|
||||
from playlist import sql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers()
|
||||
|
||||
|
||||
async def generate_sublists(
|
||||
session: AsyncSession,
|
||||
playlist_entries: set[int],
|
||||
sublist_counts: dict[enums.Sublist, int],
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
base_count: int,
|
||||
max_count: int,
|
||||
) -> None:
|
||||
"""Generate sublists for a given category and type (favorites or general).
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
playlist_entries (set[int]): The set of track IDs in the playlist.
|
||||
sublist_counts (dict[enums.Sublist, int]): The counts of existing tracks in the
|
||||
given sublist in the current playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate playlists.
|
||||
is_favorite (bool): True for favorites only, False for general tracks.
|
||||
base_count (int): The base number of tracks to be included from regular sublists.
|
||||
max_count (int): The max number of tracks to be included from regular sublists.
|
||||
"""
|
||||
for sublist in enums.Sublist.standard():
|
||||
existing_count = sublist_counts.get(sublist, 0)
|
||||
allowed_count = base_count - existing_count
|
||||
tracks = await sql.fetch_tracks_for_sublist(
|
||||
session,
|
||||
is_category,
|
||||
type_id,
|
||||
is_favorite,
|
||||
sublist,
|
||||
allowed_count,
|
||||
playlist_entries,
|
||||
)
|
||||
for track in tracks:
|
||||
await sql.insert_into_playlist(
|
||||
session, is_category, type_id, track.id, sublist, is_favorite
|
||||
)
|
||||
playlist_entries.add(track.id)
|
||||
|
||||
# Calculate how many tracks to randomly add
|
||||
remaining_count = max_count - len(playlist_entries)
|
||||
if remaining_count > 0:
|
||||
await generate_random_sublist(
|
||||
session,
|
||||
playlist_entries,
|
||||
sublist_counts.get(enums.Sublist.RANDOM, 0),
|
||||
type_id,
|
||||
is_favorite,
|
||||
remaining_count,
|
||||
)
|
||||
|
||||
|
||||
async def generate_random_sublist(
|
||||
session: AsyncSession,
|
||||
playlist_entries: set[int],
|
||||
existing_count: int,
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
count: int,
|
||||
) -> None:
|
||||
"""Generate random sublist for the remaining slots in the playlist.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
playlist_entries (set[int]): The set of track IDs in the playlist.
|
||||
existing_count (int): The number of random tracks already existing in the playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate the random sublist.
|
||||
is_favorite (bool): True for favorites only, False for general tracks.
|
||||
count (int): Number of random tracks to add.
|
||||
existing_entries (set): Set of track IDs already added to avoid duplicates.
|
||||
"""
|
||||
allowed_count = count - existing_count
|
||||
random_tracks = await sql.fetch_tracks_for_sublist(
|
||||
session,
|
||||
type_id,
|
||||
is_favorite,
|
||||
enums.Sublist.RANDOM,
|
||||
count,
|
||||
allowed_count,
|
||||
playlist_entries,
|
||||
)
|
||||
for track in random_tracks:
|
||||
await sql.insert_into_playlist(
|
||||
session, type_id, track.id, enums.Sublist.RANDOM, is_favorite
|
||||
)
|
||||
playlist_entries.add(track.id)
|
||||
if len(playlist_entries) >= count:
|
||||
break
|
||||
|
||||
|
||||
async def process_playlists(
|
||||
playlist_entries: set[int],
|
||||
playlist_counts: dict[tuple[bool, int, bool, enums.Sublist], int],
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
favorite_base: int,
|
||||
general_base: int,
|
||||
favorite_max: int,
|
||||
general_max: int,
|
||||
) -> None:
|
||||
"""Process playlists for both favorites and general tracks for a given category or holiday.
|
||||
|
||||
Args:
|
||||
playlist_entries (set[int]): The IDs of the tracks currently in the playlist.
|
||||
playlist_counts (dict[tuple[bool, int, bool, enums.Sublist], int]): Counts of the
|
||||
different sublist types within the playlist.
|
||||
is_category (bool): True if it is a category, false if it is a holiday.
|
||||
type_id (int): The category or holiday ID for which to generate the random sublist.
|
||||
favorite_base (int): The maximum number of tracks for a favorite standard sublist.
|
||||
general_base (int): The maximum number of tracks for a general standard sublist.
|
||||
favorite_max (int): The maximum number of tracks for favorite tracks.
|
||||
general_max (int): The maximum number of tracks for general tracks.
|
||||
"""
|
||||
async with sql.async_session_maker() as session:
|
||||
favorite_counts = {
|
||||
key[3]: count
|
||||
for key, count in playlist_counts.items()
|
||||
if key[0] is is_category and key[1] == type_id and key[2] is True
|
||||
}
|
||||
general_counts = {
|
||||
key[3]: count
|
||||
for key, count in playlist_counts.items()
|
||||
if key[0] is is_category and key[1] == type_id and key[2] is False
|
||||
}
|
||||
await generate_sublists(
|
||||
session,
|
||||
playlist_entries,
|
||||
favorite_counts,
|
||||
is_category,
|
||||
type_id,
|
||||
True,
|
||||
favorite_base,
|
||||
favorite_max,
|
||||
)
|
||||
await generate_sublists(
|
||||
session,
|
||||
playlist_entries,
|
||||
general_counts,
|
||||
is_category,
|
||||
type_id,
|
||||
False,
|
||||
general_base,
|
||||
general_max,
|
||||
)
|
||||
|
||||
|
||||
async def generate_playlist_for_all() -> None:
|
||||
"""Generate playlists for all categories & holidays."""
|
||||
(
|
||||
existing_track_ids,
|
||||
existing_playlist_counts,
|
||||
) = await sql.get_existing_playlist_track_info()
|
||||
category_ids = tuple(await sql.gen_all_category_ids())
|
||||
holiday_ids = tuple(sql.gen_active_holiday_ids())
|
||||
stats = await sql.get_statistics()
|
||||
category_favorite_base = stats.max_regular_favorite_base
|
||||
category_general_base = stats.max_regular_general_base
|
||||
category_favorite_max = stats.max_regular_favorite_tracks
|
||||
category_general_max = stats.max_regular_general_tracks
|
||||
holiday_favorite_base = stats.max_holiday_favorite_base
|
||||
holiday_general_base = stats.max_holiday_general_base
|
||||
holiday_favorite_max = stats.max_holiday_favorite_tracks
|
||||
holiday_general_max = stats.max_holiday_general_tracks
|
||||
tasks = [
|
||||
process_playlists(
|
||||
playlist_entries=existing_track_ids,
|
||||
playlist_counts=existing_playlist_counts,
|
||||
is_category=True,
|
||||
type_id=category_id,
|
||||
favorite_base=category_favorite_base,
|
||||
general_base=category_general_base,
|
||||
favorite_max=category_favorite_max,
|
||||
general_max=category_general_max,
|
||||
)
|
||||
for category_id in category_ids
|
||||
] + [
|
||||
process_playlists(
|
||||
is_category=False,
|
||||
type_id=holiday_id,
|
||||
favorite_base=holiday_favorite_base,
|
||||
general_base=holiday_general_base,
|
||||
favorite_max=holiday_favorite_max,
|
||||
general_max=holiday_general_max,
|
||||
)
|
||||
for holiday_id in holiday_ids
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Contains all plex code here."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import env # Make sure to import your environment configuration
|
||||
from aiologger import Logger
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers(name="plex_logger")
|
||||
executor = ThreadPoolExecutor()
|
||||
|
||||
|
||||
async def fetch_tracks_from_plex(
|
||||
server_url: str = env.PLEX_SERVER_URL, token: str = env.PLEX_TOKEN
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
"""Asynchronously fetches tracks from a Plex server.
|
||||
|
||||
Uses plexapi, employing a ThreadPoolExecutor to handle blocking calls from plexapi.
|
||||
|
||||
Args:
|
||||
server_url (str): URL of the Plex server, defaults to environment variable.
|
||||
token (str): Authentication token for accessing the Plex server, defaults to environment variable.
|
||||
|
||||
Yields:
|
||||
AsyncGenerator[Dict, None]: An asynchronous generator yielding track data dictionaries.
|
||||
"""
|
||||
try:
|
||||
# Initialize Plex server connection using plexapi in a non-blocking way
|
||||
plex = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, lambda: PlexServer(server_url, token)
|
||||
)
|
||||
|
||||
# Fetch all music sections asynchronously
|
||||
sections = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, plex.library.sections
|
||||
)
|
||||
music_section = next(
|
||||
(section for section in sections if section.type == "artist"), None
|
||||
)
|
||||
|
||||
if not music_section:
|
||||
await logger.error("No music section found on the Plex server.")
|
||||
return
|
||||
|
||||
# Asynchronously fetch tracks by paging through the music section
|
||||
total_tracks = await asyncio.get_event_loop().run_in_executor(
|
||||
executor, music_section.totalSize
|
||||
)
|
||||
size = env.MAX_TRACKS_QUERIED # Maximum number of tracks to query at once
|
||||
|
||||
for offset in range(0, total_tracks, size):
|
||||
tracks = await asyncio.get_event_loop().run_in_executor(
|
||||
executor,
|
||||
lambda offset=offset, size=size: music_section.searchTracks(
|
||||
container_start=offset, container_size=size
|
||||
),
|
||||
)
|
||||
for track in tracks:
|
||||
track_data = {
|
||||
"title": track.title,
|
||||
"artist": (
|
||||
track.grandparentTitle
|
||||
if track.grandparentTitle
|
||||
else "Unknown Artist"
|
||||
),
|
||||
"album": (
|
||||
track.parentTitle if track.parentTitle else "Unknown Album"
|
||||
),
|
||||
"cover_image": track.thumbUrl if track.thumbUrl else "",
|
||||
"duration": track.duration,
|
||||
"times_played": getattr(track, "userRatingCount", 0),
|
||||
"date_last_played": (
|
||||
track.lastViewedAt.isoformat() if track.lastViewedAt else None
|
||||
),
|
||||
"date_added": track.addedAt.isoformat(),
|
||||
"rating": getattr(track, "userRating", None),
|
||||
"genre": (
|
||||
", ".join(genre.tag for genre in track.genres)
|
||||
if track.genres
|
||||
else "Unknown"
|
||||
),
|
||||
"server_id": track.ratingKey, # Using Plex track ratingKey as the server_id
|
||||
}
|
||||
yield track_data
|
||||
except Exception as e:
|
||||
await logger.error(f"Failed to fetch tracks from Plex: {e}")
|
||||
raise
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Podcast processing/updating contained here."""
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import asyncpathlib
|
||||
import asyncssh
|
||||
import eyed3
|
||||
import feedparser
|
||||
from aiologger import Logger
|
||||
from env import PODCAST_DAYS
|
||||
from env import SSH_KEY_PATH
|
||||
from env import SSH_PATH
|
||||
from env import SSH_SERVER
|
||||
from env import SSH_USER
|
||||
from models import Episode
|
||||
from models import Podcast
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers()
|
||||
|
||||
|
||||
async def fetch_and_process_podcasts(session: AsyncSession):
|
||||
"""Asynchronously fetches all podcasts from the database and processes each one.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session to use for querying.
|
||||
"""
|
||||
podcasts = await session.execute(select(Podcast))
|
||||
tasks = [process_podcast(podcast, session) for podcast in podcasts.scalars().all()]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def process_podcast(podcast: Podcast, session: AsyncSession):
|
||||
"""Processes each podcast.
|
||||
|
||||
By parsing its feed, checking for new episodes, downloading,
|
||||
modifying, uploading, and saving them.
|
||||
|
||||
Args:
|
||||
podcast (Podcast): A podcast object containing metadata.
|
||||
session (AsyncSession): The database session to use for ORM operations.
|
||||
"""
|
||||
feed = feedparser.parse(podcast.url)
|
||||
threshold_date = datetime.now() - timedelta(days=PODCAST_DAYS)
|
||||
for entry in feed.entries:
|
||||
if datetime(*entry.published_parsed[:6]) > threshold_date:
|
||||
if not await episode_exists(session, entry.title, podcast.id):
|
||||
episode_path = await download_episode(entry)
|
||||
if episode_path:
|
||||
await modify_id3_tags(episode_path, entry.title, podcast.name)
|
||||
await upload_episode(episode_path, podcast.name)
|
||||
await save_episode(session, entry, podcast.id, episode_path)
|
||||
|
||||
|
||||
async def episode_exists(
|
||||
session: AsyncSession, episode_name: str, podcast_id: int
|
||||
) -> bool:
|
||||
"""Determines whether an episode already exists in the database.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
episode_name (str): The name of the episode to check.
|
||||
podcast_id (int): The ID of the podcast to check against.
|
||||
|
||||
Returns:
|
||||
bool: True if the episode exists, False otherwise.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(Episode).where(
|
||||
Episode.name == episode_name, Episode.podcast_id == podcast_id
|
||||
)
|
||||
)
|
||||
return result.scalar() is not None
|
||||
|
||||
|
||||
async def download_episode(entry):
|
||||
"""Download an episode's audio file to a temporary location based on its feed entry.
|
||||
|
||||
Args:
|
||||
entry (FeedParserDict): A single episode entry from the parsed feed.
|
||||
|
||||
Returns:
|
||||
asyncpathlib.Path: Path to the downloaded file, or None if the download fails.
|
||||
"""
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
episode_path = (
|
||||
asyncpathlib.Path(temp_dir.name) / f"{entry.title.replace('/', '-')}.mp3"
|
||||
)
|
||||
try:
|
||||
audio_url = entry.enclosures[0].href
|
||||
content = await fetch_content(audio_url)
|
||||
await episode_path.write_bytes(content)
|
||||
return episode_path
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download episode {entry.title}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_content(url: str) -> bytes:
|
||||
"""Fetch content from a URL asynchronously.
|
||||
|
||||
Args:
|
||||
url (str): The URL from which to fetch the content.
|
||||
|
||||
Returns:
|
||||
bytes: The content fetched from the URL.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
|
||||
async def modify_id3_tags(file_path: asyncpathlib.Path, title: str, album: str):
|
||||
"""Modify the ID3 tags of a downloaded audio file asynchronously using eyed3.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the audio file.
|
||||
title (str): The title to set in the ID3 tags.
|
||||
album (str): The album name to set in the ID3 tags.
|
||||
"""
|
||||
executor = ThreadPoolExecutor()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(executor, lambda: _edit_tags(file_path, title, album))
|
||||
|
||||
|
||||
def _edit_tags(file_path, title, album):
|
||||
"""Helper function to modify ID3 tags using eyed3, called within a ThreadPoolExecutor.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the file.
|
||||
title (str): The title to apply to the ID3 tags.
|
||||
album (str): The album name to apply to the ID3 tags.
|
||||
"""
|
||||
audiofile = eyed3.load(str(file_path))
|
||||
if audiofile.tag is None:
|
||||
audiofile.initTag()
|
||||
audiofile.tag.title = title
|
||||
audiofile.tag.album = album
|
||||
audiofile.tag.genre = "Podcast"
|
||||
audiofile.tag.save()
|
||||
|
||||
|
||||
async def upload_episode(file_path: asyncpathlib.Path, podcast_name: str):
|
||||
"""Asynchronously uploads an episode file to a remote server using SCP.
|
||||
|
||||
Args:
|
||||
file_path (asyncpathlib.Path): The path to the episode file.
|
||||
podcast_name (str): The name of the podcast, used to organize files on the server.
|
||||
"""
|
||||
remote_path = f"{SSH_PATH}/{podcast_name}/{file_path.name}"
|
||||
try:
|
||||
async with asyncssh.connect(
|
||||
host=SSH_SERVER, username=SSH_USER, client_keys=[SSH_KEY_PATH]
|
||||
) as conn:
|
||||
await conn.run(f"mkdir -p {SSH_PATH}/{podcast_name}", check=True)
|
||||
await asyncssh.scp(str(file_path), (conn, remote_path))
|
||||
logger.info(f"Successfully uploaded {file_path} to {remote_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading file {file_path} to {remote_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def save_episode(
|
||||
session: AsyncSession, entry, podcast_id: int, episode_path: asyncpathlib.Path
|
||||
):
|
||||
"""Save the details of a new podcast episode to the database after it has been processed.
|
||||
|
||||
Args:
|
||||
session (AsyncSession): The database session.
|
||||
entry (FeedParserDict): The parsed feed entry.
|
||||
podcast_id (int): The ID of the podcast.
|
||||
episode_path (asyncpathlib.Path): The path to the downloaded and processed episode file.
|
||||
"""
|
||||
new_episode = Episode(
|
||||
podcast_id=podcast_id,
|
||||
name=entry.title,
|
||||
filename=str(episode_path),
|
||||
date_published=datetime(*entry.published_parsed[:6]),
|
||||
)
|
||||
session.add(new_episode)
|
||||
await session.commit()
|
||||
logger.info(f"New episode saved: {entry.title}")
|
||||
@@ -1,677 +0,0 @@
|
||||
"""Contains all SQL code here."""
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Coroutine
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Protocol
|
||||
|
||||
import aiologger
|
||||
import sqlalchemy
|
||||
import sqlalchemy.orm
|
||||
from playlist import enums
|
||||
from playlist import env
|
||||
from playlist import models
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
|
||||
# Initialize asynchronous logger
|
||||
logger = aiologger.Logger.with_default_handlers(name=__name__)
|
||||
|
||||
# Create an asynchronous SQLAlchemy engine
|
||||
_engine = create_async_engine(env.DATABASE_URL, echo=True)
|
||||
|
||||
# Create a session maker for asynchronous sessions
|
||||
_async_session_maker = sqlalchemy.orm.sessionmaker(
|
||||
_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
class Sessionizable(Protocol):
|
||||
"""Protocol defining the signature of a sessionizable function."""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*args: tuple[Any, ...],
|
||||
session: AsyncSession | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> (AsyncGenerator, Coroutine):
|
||||
"""The signature for a sessionizable function.
|
||||
|
||||
Args:
|
||||
args: The positional arguments for the function.
|
||||
kwargs: The keyword arguments for the function (except for session).
|
||||
|
||||
Keyword Args:
|
||||
session: The database session. If set to None, this will be filled with a new session.
|
||||
|
||||
Returns:
|
||||
(AsyncGenerator, Coroutine): The async generator or
|
||||
coroutine that would be created by this function.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _db_logger(
|
||||
func_name: str, session: AsyncSession
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""Asynchronous context manager for logging database errors and handling rollbacks.
|
||||
|
||||
This context manager logs SQLAlchemy-specific exceptions and other exceptions that occur during
|
||||
the execution of a function. It also handles rolling back the session in case of an error.
|
||||
|
||||
Args:
|
||||
func_name (str): The name of the function being executed.
|
||||
session (AsyncSession): The database session used in the function.
|
||||
|
||||
Yields:
|
||||
None: This context manager does not yield any value.
|
||||
|
||||
Raises:
|
||||
SQLAlchemyError: Re-raises any SQLAlchemy-specific exceptions after logging and rollback.
|
||||
Exception: Re-raises any other exceptions after logging and rollback.
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
await logger.exception(f"Database error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
await logger.exception(f"Unexpected error in {func_name}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _validate_signature(func: Sessionizable) -> None:
|
||||
"""Validate that the given function has a 'session' keyword argument.
|
||||
|
||||
This function checks if the 'session' parameter is present in the function's signature.
|
||||
If the 'session' parameter is missing, it raises an UnboundLocalError.
|
||||
|
||||
Args:
|
||||
func (Sessionizable): The function to validate.
|
||||
|
||||
Raises:
|
||||
UnboundLocalError: If the function does not have a 'session' parameter.
|
||||
"""
|
||||
sig = inspect.signature(func)
|
||||
try:
|
||||
sig.parameters["session"]
|
||||
|
||||
except KeyError as e:
|
||||
msg = f"{func.__name__} does not have a session parameter."
|
||||
raise UnboundLocalError(msg) from e
|
||||
|
||||
|
||||
def sessionize(func: Sessionizable) -> Sessionizable:
|
||||
"""Decorator that ensures a database session is available to an async function or generator.
|
||||
|
||||
This decorator automatically injects a `session` of type AsyncSession
|
||||
into the decorated function if one is not provided. If the `session` keyword argument is
|
||||
missing or set to None, a new session is created using the `_async_session_maker` and passed
|
||||
to the function. If a session is already provided when the function is called, it uses the
|
||||
existing session.
|
||||
|
||||
The decorator is intended for use with coroutine functions and async generator functions that
|
||||
are expected to interact with a database using SQLAlchemy's asynchronous session management.
|
||||
|
||||
Features:
|
||||
- Automatically manages database sessions to ensure efficient and correct usage of database
|
||||
connections.
|
||||
- Logs any SQLAlchemy-specific exceptions or other exceptions that occur during the execution
|
||||
of the decorated function, handling rollbacks if necessary.
|
||||
- Ensures that functions without a proper 'session' keyword argument are not decorated, raising
|
||||
an UnboundLocalError.
|
||||
|
||||
Args:
|
||||
func (Sessionizable): A coroutine function or an async generator function that accepts a
|
||||
'session' keyword argument.
|
||||
|
||||
Returns:
|
||||
Sessionizable: A wrapped version of the input function that manages a database session.
|
||||
|
||||
Raises:
|
||||
TypeError: If `func` is neither a coroutine function nor an async generator function.
|
||||
UnboundLocalError: If `func` lacks a 'session' keyword argument.
|
||||
|
||||
Example:
|
||||
@sessionize
|
||||
async def fetch_data(session: AsyncSession | None = None):
|
||||
# Function body using session
|
||||
...
|
||||
|
||||
Note:
|
||||
- The function must include a 'session' parameter in its signature, which can be None by
|
||||
default.
|
||||
- This decorator is only applicable to functions intended to perform database operations
|
||||
asynchronously.
|
||||
|
||||
See Also:
|
||||
- Sessionizable: The Protocol for sessionizable functions.
|
||||
- AsyncSession: SQLAlchemy class used for asynchronous session management.
|
||||
"""
|
||||
_validate_signature(func)
|
||||
|
||||
ret: Sessionizable
|
||||
match func:
|
||||
case func if inspect.iscoroutinefunction(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _coro_wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> Coroutine:
|
||||
"""Wrap a sessionized coroutine function to inject the session if needed.
|
||||
|
||||
This wrapper function manages the database session for coroutine functions.
|
||||
"""
|
||||
if kwargs["session"] is None:
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
ret = _coro_wrapper
|
||||
|
||||
case func if inspect.isasyncgenfunction(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _asyncgen_wrapper[
|
||||
**P
|
||||
](*args: P.args, **kwargs: P.kwargs) -> AsyncGenerator:
|
||||
"""Wrap a sessionized async generator function to inject the session if needed.
|
||||
|
||||
This wrapper function manages the database session for async generator functions.
|
||||
"""
|
||||
if kwargs["session"] is None:
|
||||
async with (
|
||||
_async_session_maker() as kwargs["session"],
|
||||
_db_logger(func.__name__, kwargs["session"]),
|
||||
):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
else:
|
||||
async with _db_logger(func.__name__, kwargs["session"]):
|
||||
async for element in func(*args, **kwargs):
|
||||
yield element
|
||||
|
||||
ret = _asyncgen_wrapper
|
||||
|
||||
case _:
|
||||
msg = f"{func.__name__} is not an async generator or coroutine function."
|
||||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables asynchronously."""
|
||||
try:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(models.mapper_registry.metadata.create_all)
|
||||
await logger.info("Database tables created.")
|
||||
except Exception as e:
|
||||
await logger.exception(f"Failed to create tables: {e}")
|
||||
|
||||
|
||||
async def drop_db() -> None:
|
||||
"""Drop all tables asynchronously for clean slate testing or teardown."""
|
||||
try:
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(models.mapper_registry.metadata.drop_all)
|
||||
await logger.info("Database tables dropped.")
|
||||
except Exception as e:
|
||||
await logger.exception(f"Failed to drop tables: {e}")
|
||||
|
||||
|
||||
@sessionize
|
||||
async def insert_or_update_track(
|
||||
track_data: dict[str, Any],
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a new track or update an existing one in the database asynchronously.
|
||||
|
||||
Args:
|
||||
track_data: The track data to write to the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
async with session.begin():
|
||||
genre = await session.get(models.Genre, track_data.get("genre_id"))
|
||||
if not genre:
|
||||
genre = models.Genre(name=track_data["genre_name"])
|
||||
session.add(genre)
|
||||
await session.flush() # Ensures 'genre' is persisted and has an 'id'
|
||||
|
||||
track = await session.get(models.Track, track_data.get("id"))
|
||||
if track:
|
||||
for key, value in track_data.items():
|
||||
setattr(track, key, value)
|
||||
await logger.info(f"Updated track: {track.title}")
|
||||
else:
|
||||
track = models.Track(**track_data)
|
||||
session.add(track)
|
||||
await logger.info(f"Inserted new track: {track.title}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def cleanup_unused_genres(*, session: AsyncSession | None = None) -> None:
|
||||
"""Remove genres that are no longer used by any tracks.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
"""
|
||||
async with session.begin():
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Genre)
|
||||
.outerjoin(models.Track)
|
||||
.filter(models.Track.id is None)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
unused_genres = result.scalars().all()
|
||||
|
||||
for genre in unused_genres:
|
||||
await session.delete(genre)
|
||||
|
||||
await session.commit()
|
||||
await logger.info(f"Cleaned up {len(unused_genres)} unused genres.")
|
||||
|
||||
|
||||
@sessionize
|
||||
async def set_timestamp(
|
||||
event: enums.Event,
|
||||
operation: enums.TimedEvent,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> datetime:
|
||||
"""Mark the start or end timestamps in the statistics model.
|
||||
|
||||
Args:
|
||||
event: Identifies what event is being affected.
|
||||
operation: Identifies if it is the start or end of the event.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
datetime: The datetime of the last update.
|
||||
"""
|
||||
async with session.begin():
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
stats_obj = stats.scalars().first()
|
||||
|
||||
match [event, operation]:
|
||||
case [enums.Event.UPDATER, enums.EventTime.START]:
|
||||
stats_obj.start_update = datetime.now()
|
||||
log_message = "Updater start timestamp updated."
|
||||
case [enums.Event.UPDATER, enums.EventTime.END]:
|
||||
stats_obj.end_update = datetime.now()
|
||||
log_message = "Updater end timestamp updated."
|
||||
case [enums.Event.GENERATOR, enums.EventTime.START]:
|
||||
stats_obj.start_playlist_gen = datetime.now()
|
||||
log_message = "Generator start timestamp updated."
|
||||
case [enums.Event.GENERATOR, enums.EventTime.END]:
|
||||
stats_obj.end_playlist_gen = datetime.now()
|
||||
log_message = "Generator end timestamp updated."
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Invalid event {event} or operation {operation} specified."
|
||||
)
|
||||
|
||||
match event:
|
||||
case enums.Event.UPDATER:
|
||||
last_updated = stats_obj.end_update
|
||||
case enums.Event.GENERATOR:
|
||||
last_updated = stats_obj.end_playlist_gen
|
||||
case _:
|
||||
raise ValueError(f"Invalid event {event} specified.")
|
||||
|
||||
await session.commit()
|
||||
await logger.info(log_message)
|
||||
|
||||
return last_updated
|
||||
|
||||
|
||||
@sessionize
|
||||
async def remove_holiday_by_id(
|
||||
holiday_id: int, *, session: AsyncSession | None = None
|
||||
) -> None:
|
||||
"""Remove a holiday from the database by ID.
|
||||
|
||||
Captures and logs database-specific errors and general exceptions.
|
||||
|
||||
Args:
|
||||
holiday_id: The holiday ID to remove.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
holiday = await session.get(models.Holiday, holiday_id)
|
||||
if holiday:
|
||||
await session.delete(holiday)
|
||||
await session.commit()
|
||||
else:
|
||||
await logger.warning(f"No holiday found with ID: {holiday_id}")
|
||||
|
||||
|
||||
@sessionize
|
||||
async def update_statistics(
|
||||
new_average_duration: float,
|
||||
new_podcast_length: float,
|
||||
new_max_playtime: float,
|
||||
new_category_count: int,
|
||||
new_active_holidays: int,
|
||||
new_max_tracks: int,
|
||||
new_max_regular: int,
|
||||
new_max_holiday: int,
|
||||
new_max_regular_favorite: int,
|
||||
new_max_regular_general: int,
|
||||
new_max_holiday_favorite: int,
|
||||
new_max_holiday_general: int,
|
||||
new_max_regular_favorite_base: int,
|
||||
new_max_regular_general_base: int,
|
||||
new_max_holiday_favorite_base: int,
|
||||
new_max_holiday_general_base: int,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Update the statistics model.
|
||||
|
||||
Args:
|
||||
new_average_duration: The new average duration of a track by geometric mean
|
||||
of all track durations as a float.
|
||||
new_podcast_length: The new total podcast length of all current episodes as a float.
|
||||
new_max_playtime: The new max playtime as a float.
|
||||
new_category_count: The new count of categories as an int.
|
||||
new_active_holidays: The new active holidays as an int.
|
||||
new_max_tracks: The new max tracks per day as an int.
|
||||
new_max_regular: The new max regular tracks as an int.
|
||||
new_max_holiday: The new max holiday tracks as an int.
|
||||
new_max_regular_favorite: The new max regular favorite tracks as an int.
|
||||
new_max_regular_general: The new max regular general tracks as an int.
|
||||
new_max_holiday_favorite: The new max holiday favorite tracks as an int.
|
||||
new_max_holiday_general: The new max holiday general tracks as an int.
|
||||
new_max_regular_favorite_base: The new max regular favorite base unit as an int.
|
||||
new_max_regular_general_base: The new max regular general base unit as an int.
|
||||
new_max_holiday_favorite_base: The new max holiday favorite base unit as an int.
|
||||
new_max_holiday_general_base: The new max holiday general base unit as an int.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
async with session.begin():
|
||||
# Assuming there's only one statistics record, or you might need to handle this differently
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
if stats:
|
||||
stats.average_track_length = new_average_duration
|
||||
stats.total_podcast_length = new_podcast_length
|
||||
stats.max_playtime = new_max_playtime
|
||||
stats.category_count = new_category_count
|
||||
stats.active_holiday_count = new_active_holidays
|
||||
stats.max_tracks_per_day = new_max_tracks
|
||||
stats.max_regular_tracks = new_max_regular
|
||||
stats.max_holiday_tracks = new_max_holiday
|
||||
stats.max_regular_favorite_tracks = new_max_regular_favorite
|
||||
stats.max_regular_general_tracks = new_max_regular_general
|
||||
stats.max_holiday_favorite_tracks = new_max_holiday_favorite
|
||||
stats.max_holiday_general_tracks = new_max_holiday_general
|
||||
stats.max_regular_favorite_base = new_max_regular_favorite_base
|
||||
stats.max_regular_general_base = new_max_regular_general_base
|
||||
stats.max_holiday_favorite_base = new_max_holiday_favorite_base
|
||||
stats.max_holiday_general_base = new_max_holiday_general_base
|
||||
await session.commit()
|
||||
else:
|
||||
# If no statistics entry exists, create one
|
||||
new_stats = models.Statistics(
|
||||
average_track_length=new_average_duration,
|
||||
total_podcast_length=new_podcast_length,
|
||||
max_playtime=new_max_playtime,
|
||||
active_holiday_count=new_active_holidays,
|
||||
max_tracks_per_day=new_max_tracks,
|
||||
max_regular_tracks=new_max_regular,
|
||||
max_holiday_tracks=new_max_holiday,
|
||||
max_regular_favorite_tracks=new_max_regular_favorite,
|
||||
max_regular_general_tracks=new_max_regular_general,
|
||||
max_holiday_favorite_tracks=new_max_holiday_favorite,
|
||||
max_holiday_general_tracks=new_max_holiday_general,
|
||||
max_regular_favorite_base=new_max_regular_favorite_base,
|
||||
max_regular_general_base=new_max_regular_general_base,
|
||||
max_holiday_favorite_base=new_max_holiday_favorite_base,
|
||||
max_holiday_general_base=new_max_holiday_general_base,
|
||||
)
|
||||
session.add(new_stats)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def fetch_tracks_for_sublist(
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
is_favorite: bool,
|
||||
sublist: enums.Sublist,
|
||||
limit: int,
|
||||
playlist_entries: set[int],
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> AsyncGenerator[models.Track, None]:
|
||||
"""Fetch tracks for a given sublist type within a category.
|
||||
|
||||
Args:
|
||||
is_category: True if a category, false if a holiday.
|
||||
type_id: Category or holiday ID for track filtering.
|
||||
is_favorite: Flag indicating if only favorite tracks should be fetched.
|
||||
sublist: Type of sublist to fetch.
|
||||
limit: Number of tracks to fetch.
|
||||
playlist_entries: The set of track IDs already in the playlist.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
AsyncGenerator[models.Track, None]: Generates the tracks fitting the
|
||||
criteria.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Track)
|
||||
if is_category:
|
||||
query = query.where(models.Track.category_id == type_id)
|
||||
else:
|
||||
query = query.join(models.Track.holidays).where(models.Holiday.id == type_id)
|
||||
|
||||
query = query.filter(models.Track.id.not_in(playlist_entries))
|
||||
|
||||
if is_favorite:
|
||||
query = query.filter(models.Track.rating == 5)
|
||||
|
||||
match sublist:
|
||||
case enums.Sublist.LEAST_RECENTLY_PLAYED:
|
||||
query = query.order_by(models.Track.last_played.asc())
|
||||
case enums.Sublist.LEAST_OFTEN_PLAYED:
|
||||
query = query.order_by(models.Track.play_count.asc())
|
||||
case enums.Sublist.LEAST_RECENTLY_ADDED:
|
||||
query = query.order_by(models.Track.date_added.asc())
|
||||
case enums.Sublist.RANDOM:
|
||||
query = query.order_by(sqlalchemy.func.random())
|
||||
case _:
|
||||
raise ValueError(f"Unknown sublist type: {sublist}")
|
||||
|
||||
query = query.limit(limit)
|
||||
result = await session.execute(query)
|
||||
for track in result.scalars():
|
||||
yield track
|
||||
|
||||
|
||||
@sessionize
|
||||
async def insert_into_playlist( # noqa: C901
|
||||
is_category: bool,
|
||||
type_id: int,
|
||||
track_id: int,
|
||||
sublist: enums.Sublist,
|
||||
is_favorite: bool,
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> None:
|
||||
"""Insert a track into the playlist.
|
||||
|
||||
Args:
|
||||
is_category: True is a category, false is a holiday.
|
||||
type_id: Category or holiday ID associated with the playlist.
|
||||
track_id: Track ID to be added to the playlist.
|
||||
sublist: Sublist type under which the track is added.
|
||||
is_favorite: Indicates if the track is added as a favorite.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
""" # noqa: D417
|
||||
if is_category:
|
||||
playlist_track = models.PlaylistTrack(
|
||||
category_id=type_id,
|
||||
track_id=track_id,
|
||||
sublist_type=sublist,
|
||||
is_favorite=is_favorite,
|
||||
)
|
||||
else:
|
||||
playlist_track = models.PlaylistTrack(
|
||||
holiday_id=type_id,
|
||||
track_id=track_id,
|
||||
sublist_type=sublist,
|
||||
is_favorite=is_favorite,
|
||||
)
|
||||
session.add(playlist_track)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@sessionize
|
||||
async def gen_all_category_ids(
|
||||
*, session: AsyncSession | None = None
|
||||
) -> AsyncGenerator[int, None, None]:
|
||||
"""Generate the current category ids from the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Yields:
|
||||
int: The category id.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Category)
|
||||
result = session.execute(query)
|
||||
for row in result.scalars():
|
||||
yield row.id
|
||||
|
||||
|
||||
@sessionize
|
||||
async def gen_active_holiday_ids(
|
||||
*, session: AsyncSession | None = None
|
||||
) -> AsyncGenerator[int, None, None]:
|
||||
"""Generate the current active holiday ids from the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Yields:
|
||||
int: The holiday id.
|
||||
"""
|
||||
query = sqlalchemy.select(models.Holiday).where(models.Holiday.is_active is True)
|
||||
result = session.execute(query)
|
||||
for row in result.scalars():
|
||||
yield row.id
|
||||
|
||||
|
||||
@sessionize
|
||||
async def get_existing_playlist_track_info(
|
||||
*,
|
||||
session: AsyncSession | None = None,
|
||||
) -> tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]:
|
||||
"""Get the existing playlist track information.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
tuple[set[int], dict[tuple[bool, int, bool, enums.Sublist], int]]: Two pieces:
|
||||
First is a set of the ids of all of the tracks in the playlist. Second is a
|
||||
dict with the key being the combination of is_category, type_id, is_favorite,
|
||||
and sublist; the value is the count of that unique key combination.
|
||||
"""
|
||||
# TODO: Set up user-specific query here.
|
||||
query = sqlalchemy.select(models.PlaylistTrack).where(
|
||||
models.PlaylistTrack.episode_id is None
|
||||
)
|
||||
result = session.execute(query)
|
||||
ids = set()
|
||||
data = []
|
||||
for row in result.scalars():
|
||||
if row.episode_id is not None:
|
||||
continue
|
||||
ids.append(row.track_id)
|
||||
is_category = row.category_id is not None
|
||||
type_id = row.category_id if is_category else row.holiday_id
|
||||
is_favorite = row.is_favorite
|
||||
sublist = row.sublist
|
||||
data.append((is_category, type_id, is_favorite, sublist))
|
||||
data_counts = collections.Counter(data)
|
||||
return ids, data_counts
|
||||
|
||||
|
||||
@sessionize
|
||||
async def get_statistics(*, session: AsyncSession | None = None) -> models.Statistics:
|
||||
"""Get the statistics object for the database.
|
||||
|
||||
Keyword Args:
|
||||
session: The database session.
|
||||
|
||||
Returns:
|
||||
models.Statistics: The statistics object used for playlist processing.
|
||||
"""
|
||||
stats = await session.get(models.Statistics, 1)
|
||||
stats_obj = stats.scalars().first()
|
||||
return stats_obj
|
||||
|
||||
|
||||
@sessionize
|
||||
async def remove_track_from_playlist(
|
||||
server_id: str, *, session: AsyncSession | None = None
|
||||
):
|
||||
"""Directly removes all entries from PlaylistTrack that are associated with a given server_id.
|
||||
|
||||
Args:
|
||||
server_id: The unique server ID of the track to remove from the playlist.
|
||||
|
||||
Keyword Args:
|
||||
session: The current database session.
|
||||
|
||||
Returns:
|
||||
int: Number of rows affected by the delete operation.
|
||||
"""
|
||||
# Delete directly using a join on Track where server_id matches
|
||||
delete_query = sqlalchemy.delete(models.PlaylistTrack).where(
|
||||
models.PlaylistTrack.track_id
|
||||
== sqlalchemy.select(models.Track.id)
|
||||
.where(models.Track.server_id == server_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
result = await session.execute(delete_query)
|
||||
await session.commit()
|
||||
|
||||
affected_rows = result.rowcount
|
||||
logger.info(
|
||||
f"Removed {affected_rows} entries from PlaylistTrack for server_id {server_id}."
|
||||
)
|
||||
return affected_rows
|
||||
@@ -1,127 +0,0 @@
|
||||
"""Handles updating the tracks stored in the database from plex."""
|
||||
|
||||
import enums
|
||||
import env
|
||||
import numpy as np
|
||||
import plex
|
||||
import sql
|
||||
from aiologger import Logger
|
||||
|
||||
|
||||
logger = Logger.with_default_handlers(name="updater_logger")
|
||||
|
||||
|
||||
async def process_tracks(server_url: str, token: str) -> None:
|
||||
"""Asynchronously processes track data fetched from a Plex server.
|
||||
|
||||
Updates tracks in the database, and handles genre cleanup after updates.
|
||||
|
||||
Args:
|
||||
server_url (str): The URL of the Plex server.
|
||||
token (str): The authentication token used for accessing the Plex server.
|
||||
"""
|
||||
found_genres = set()
|
||||
durations: list[float] = []
|
||||
try:
|
||||
with sql.async_session_maker() as session:
|
||||
last_updated = await sql.update_timestamp(
|
||||
session, enums.Event.UPDATER, enums.Event.START
|
||||
)
|
||||
|
||||
async for track_data in plex.fetch_tracks_from_plex(server_url, token):
|
||||
if track_data["date_last_played"] > last_updated:
|
||||
await sql.remove_track_from_playlist(
|
||||
session, track_data["server_id"]
|
||||
)
|
||||
logger.info(
|
||||
f"Track {track_data['server_id']} with title {track_data['title']}"
|
||||
f" by artist {track_data['artist']} removed from playlist because it"
|
||||
" was recently played."
|
||||
)
|
||||
await sql.insert_or_update_track(session, track_data, found_genres)
|
||||
# TODO: Exclude podcast episodes from the list of durations.
|
||||
if (
|
||||
track_data["duration"] > 0
|
||||
): # Ensure only positive durations are added
|
||||
durations.append(track_data["duration"])
|
||||
else:
|
||||
logger.warning(
|
||||
f"Track {track_data['server_id']} with title {track_data['title']}"
|
||||
f" by artist {track_data['artist']} has a duration of: {track_data['duration']}."
|
||||
)
|
||||
|
||||
await sql.cleanup_unused_genres(session, found_genres)
|
||||
|
||||
await calculate_statistics(session, durations)
|
||||
|
||||
await sql.update_timestamp(session, enums.Event.UPDATER, enums.Event.END)
|
||||
await logger.info("Track processing completed successfully.")
|
||||
except Exception as e:
|
||||
await logger.error(f"An error occurred during track processing: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def calculate_statistics(durations: list[float]) -> None:
|
||||
"""Calculate the statistics to be used for playlist processing.
|
||||
|
||||
Args:
|
||||
durations (list[float]): The list of durations of all tracks gathered during
|
||||
the track updating process.
|
||||
"""
|
||||
if durations:
|
||||
average_duration = np.exp(np.log(durations).mean())
|
||||
|
||||
# TODO: Get the total duration of all podcast episodes.
|
||||
total_podcast_length = 0
|
||||
|
||||
max_playtime = env.MAX_PLAYTIME - total_podcast_length
|
||||
max_tracks_per_day = int(max_playtime / average_duration)
|
||||
|
||||
# TODO: Calculate the number of categories in the database.abs
|
||||
category_count = 1
|
||||
|
||||
# TODO: Calculate the amount of the playlist that will be for active holidays.
|
||||
active_holidays = 0
|
||||
|
||||
ratio_denom = active_holidays * 2 + 1
|
||||
|
||||
max_regular_percentage = (active_holidays + 1) / ratio_denom / category_count
|
||||
max_holiday_percentage = 1 / ratio_denom if active_holidays else 0
|
||||
|
||||
max_regular_tracks = int(max_tracks_per_day * max_regular_percentage)
|
||||
max_holiday_tracks = int(max_tracks_per_day * max_holiday_percentage)
|
||||
|
||||
max_regular_favorite_tracks = int(max_regular_tracks / 3)
|
||||
max_regular_general_tracks = int(2 * max_regular_tracks / 3)
|
||||
|
||||
max_holiday_favorite_tracks = int(max_holiday_tracks / 3)
|
||||
max_holiday_general_tracks = int(2 * max_holiday_tracks / 3)
|
||||
|
||||
max_regular_favorite_base = int(max_regular_favorite_tracks / 6)
|
||||
max_regular_general_base = int(max_regular_general_tracks / 6)
|
||||
|
||||
max_holiday_favorite_base = int(max_holiday_favorite_tracks / 6)
|
||||
max_holiday_general_base = int(max_holiday_general_tracks / 6)
|
||||
|
||||
await sql.update_statistics(
|
||||
average_duration,
|
||||
total_podcast_length,
|
||||
max_playtime,
|
||||
category_count,
|
||||
active_holidays,
|
||||
max_tracks_per_day,
|
||||
max_regular_tracks,
|
||||
max_holiday_tracks,
|
||||
max_regular_favorite_tracks,
|
||||
max_regular_general_tracks,
|
||||
max_regular_favorite_base,
|
||||
max_regular_general_base,
|
||||
max_holiday_favorite_tracks,
|
||||
max_holiday_general_tracks,
|
||||
max_holiday_favorite_base,
|
||||
max_holiday_general_base,
|
||||
)
|
||||
|
||||
# TODO: Remove any tracks that exceed a sublist's limit for the playlist after these adjustments.
|
||||
else:
|
||||
logger.warning("No valid track durations retrieved, statistics not updated.")
|
||||
3
docs/codeofconduct.md
Normal file
3
docs/codeofconduct.md
Normal file
@@ -0,0 +1,3 @@
|
||||
```{include} ../CODE_OF_CONDUCT.md
|
||||
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Sphinx configuration."""
|
||||
project = "Plex Playlist"
|
||||
author = "Cliff Hill"
|
||||
copyright = "2023, Cliff Hill"
|
||||
copyright = "2025, Cliff Hill"
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
@@ -1,4 +1,4 @@
|
||||
```{include} ../../CONTRIBUTING.md
|
||||
```{include} ../CONTRIBUTING.md
|
||||
---
|
||||
end-before: <!-- github-only -->
|
||||
---
|
||||
@@ -1,4 +1,4 @@
|
||||
```{include} ../../README.md
|
||||
```{include} ../README.md
|
||||
---
|
||||
end-before: <!-- github-only -->
|
||||
---
|
||||
@@ -1,6 +1,6 @@
|
||||
# License
|
||||
|
||||
```{literalinclude} ../../LICENSE
|
||||
```{literalinclude} ../LICENSE
|
||||
---
|
||||
language: none
|
||||
---
|
||||
18
noxfile.py
18
noxfile.py
@@ -149,7 +149,7 @@ def safety(session: Session) -> None:
|
||||
@session(python=python_versions)
|
||||
def mypy(session: Session) -> None:
|
||||
"""Type-check using mypy."""
|
||||
args = session.posargs or ["backend/src", "backend/tests", "backend/docs/conf.py"]
|
||||
args = session.posargs or ["src", "tests", "docs/conf.py"]
|
||||
session.install(".")
|
||||
session.install("mypy", "pytest")
|
||||
session.run("mypy", *args)
|
||||
@@ -163,15 +163,7 @@ def tests(session: Session) -> None:
|
||||
session.install(".")
|
||||
session.install("coverage[toml]", "pytest", "pygments")
|
||||
try:
|
||||
session.run(
|
||||
"coverage",
|
||||
"run",
|
||||
"--parallel",
|
||||
"-m",
|
||||
"pytest",
|
||||
"backend/tests",
|
||||
*session.posargs,
|
||||
)
|
||||
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
|
||||
finally:
|
||||
if session.interactive:
|
||||
session.notify("coverage", posargs=[])
|
||||
@@ -195,9 +187,7 @@ def typeguard(session: Session) -> None:
|
||||
"""Runtime type checking using Typeguard."""
|
||||
session.install(".")
|
||||
session.install("pytest", "typeguard", "pygments")
|
||||
session.run(
|
||||
"pytest", "backend/tests", f"--typeguard-packages={package}", *session.posargs
|
||||
)
|
||||
session.run("pytest", f"--typeguard-packages={package}", *session.posargs)
|
||||
|
||||
|
||||
@session(python=python_versions)
|
||||
@@ -212,7 +202,7 @@ def xdoctest(session: Session) -> None:
|
||||
|
||||
session.install(".")
|
||||
session.install("xdoctest[colors]")
|
||||
session.run("python", "-m", "xdoctest", "backend/src", *args)
|
||||
session.run("python", "-m", "xdoctest", *args)
|
||||
|
||||
|
||||
@session(name="docs-build", python=python_versions[0])
|
||||
|
||||
4491
poetry.lock
generated
4491
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,31 +3,24 @@ name = "plex-playlist"
|
||||
version = "0.0.0"
|
||||
description = "Plex Playlist"
|
||||
authors = ["Cliff Hill <xlorep@darkhelm.org>"]
|
||||
license = "MIT"
|
||||
license = "Apache-2.0"
|
||||
readme = "README.md"
|
||||
homepage = "https://github.com/xlorepdarkhelm/plex-playlist"
|
||||
repository = "https://github.com/xlorepdarkhelm/plex-playlist"
|
||||
documentation = "https://plex-playlist.readthedocs.io"
|
||||
packages = [
|
||||
{ include = "playlist", from = "backend/src" },
|
||||
{ include = "playlist", from = "src" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 1 - Planning",
|
||||
"Development Status :: 2 - Pre-Alpha",
|
||||
]
|
||||
|
||||
[tool.poetry.urls]
|
||||
Changelog = "https://github.com/xlorepdarkhelm/plex-playlist/releases"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
python = "^3.7"
|
||||
click = ">=8.0.1"
|
||||
asyncpg = "^0.28.0"
|
||||
sqlalchemy = "^2.0.23"
|
||||
pydantic = "^2.5.0"
|
||||
alembic = "^1.12.1"
|
||||
python-dotenv = "^1.0.1"
|
||||
aiologger = "^0.7.0"
|
||||
numpy = "^1.26.4"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Pygments = ">=2.10.0"
|
||||
@@ -58,9 +51,6 @@ myst-parser = {version = ">=0.16.1"}
|
||||
[tool.poetry.scripts]
|
||||
plex-playlist = "playlist.__main__:main"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
jupyter = "^1.0.0"
|
||||
|
||||
[tool.coverage.paths]
|
||||
source = ["src", "*/site-packages"]
|
||||
tests = ["tests", "*/tests"]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Command-line interface."""
|
||||
|
||||
import click
|
||||
|
||||
|
||||
284
src/playlist/loader.py
Normal file
284
src/playlist/loader.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from plexapi.server import PlexServer
|
||||
import sys
|
||||
import os
|
||||
import toml
|
||||
import argparse
|
||||
from config_path import ConfigPath
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import aiofiles
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Configuration file setup using config-path
|
||||
CONFIG_DIR: ConfigPath = ConfigPath("PlexPlaylist", "Plex", ".config")
|
||||
CONFIG_FILE: str = CONFIG_DIR.path("plex_config.toml")
|
||||
|
||||
# Executor for blocking operations
|
||||
executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
@dataclass
|
||||
class Track:
|
||||
"""Dataclass representing a music track from Plex.
|
||||
|
||||
Attributes:
|
||||
track_number: The track number or "N/A" if not available.
|
||||
disc_number: The disc number or "N/A" if not applicable.
|
||||
title: The title of the track.
|
||||
track_artist: The artist of the track.
|
||||
album_title: The title of the album.
|
||||
album_artist: The artist of the album.
|
||||
year: The release year of the album or "N/A".
|
||||
genres: List of genres associated with the track.
|
||||
date_added: Date the track was added to the library (YYYY-MM-DD).
|
||||
last_played: Date the track was last played (YYYY-MM-DD) or "Never".
|
||||
play_count: Number of times the track has been played.
|
||||
rating: User rating of the track or "Not Rated".
|
||||
"""
|
||||
track_number: str
|
||||
disc_number: str
|
||||
title: str
|
||||
track_artist: str
|
||||
album_title: str
|
||||
album_artist: str
|
||||
year: str
|
||||
genres: list[str]
|
||||
date_added: str
|
||||
last_played: str
|
||||
play_count: int
|
||||
rating: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the track for printing.
|
||||
|
||||
Returns:
|
||||
Formatted string with all track details.
|
||||
"""
|
||||
output: list[str] = [f"Track #{self.track_number}"]
|
||||
if self.disc_number != "N/A":
|
||||
output.append(f"Disc #{self.disc_number}")
|
||||
output.extend([
|
||||
f"Title: {self.title}",
|
||||
f"Track Artist: {self.track_artist}",
|
||||
f"Album: {self.album_title}",
|
||||
f"Album Artist: {self.album_artist}",
|
||||
f"Year: {self.year}",
|
||||
f"Genre: {', '.join(self.genres) if self.genres else 'N/A'}",
|
||||
f"Date Added: {self.date_added}",
|
||||
f"Last Played: {self.last_played}",
|
||||
f"Play Count: {self.play_count}",
|
||||
f"Rating: {self.rating}",
|
||||
"-" * 30
|
||||
])
|
||||
return "\n".join(output)
|
||||
|
||||
async def save_config(baseurl: str, token: str) -> None:
|
||||
"""Save Plex configuration to a local TOML file asynchronously.
|
||||
|
||||
Args:
|
||||
baseurl: The URL of the Plex server (e.g., http://192.168.1.100:32400).
|
||||
token: The authentication token for the Plex server.
|
||||
|
||||
Raises:
|
||||
Exception: If there's an error writing to the config file.
|
||||
"""
|
||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(executor, os.makedirs, os.path.dirname(CONFIG_FILE), {"exist_ok": True})
|
||||
|
||||
config: dict[str, dict[str, str]] = {
|
||||
"plex": {
|
||||
"baseurl": baseurl,
|
||||
"token": token
|
||||
}
|
||||
}
|
||||
async with aiofiles.open(CONFIG_FILE, 'w') as f:
|
||||
await f.write(toml.dumps(config))
|
||||
print("Configuration saved successfully")
|
||||
|
||||
async def load_config() -> tuple[str | None, str | None]:
|
||||
"""Load Plex configuration from local TOML file asynchronously.
|
||||
|
||||
Returns:
|
||||
A tuple of (baseurl, token) if found, else (None, None).
|
||||
"""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
async with aiofiles.open(CONFIG_FILE, 'r') as f:
|
||||
content: str = await f.read()
|
||||
config: dict[str, dict[str, str]] = toml.loads(content)
|
||||
return config.get("plex", {}).get("baseurl"), config.get("plex", {}).get("token")
|
||||
return None, None
|
||||
|
||||
async def connect_to_plex(baseurl: str, token: str) -> PlexServer | None:
|
||||
"""Connect to Plex server with given credentials asynchronously.
|
||||
|
||||
Args:
|
||||
baseurl: The URL of the Plex server.
|
||||
token: The authentication token for the Plex server.
|
||||
|
||||
Returns:
|
||||
PlexServer instance if successful, None if connection fails.
|
||||
"""
|
||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
try:
|
||||
plex: PlexServer = await loop.run_in_executor(executor, PlexServer, baseurl, token)
|
||||
return plex
|
||||
except Exception as e:
|
||||
print(f"Error connecting to Plex server: {e}")
|
||||
return None
|
||||
|
||||
async def process_track_batch(tracks: list, batch_start: int, batch_end: int, total_tracks: int) -> list[Track]:
|
||||
"""Process a batch of tracks asynchronously and return a list of Track objects.
|
||||
|
||||
Args:
|
||||
tracks: List of raw Plex track objects.
|
||||
batch_start: Starting index of the batch.
|
||||
batch_end: Ending index of the batch.
|
||||
total_tracks: Total number of tracks for progress reporting.
|
||||
|
||||
Returns:
|
||||
List of processed Track objects for this batch.
|
||||
"""
|
||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
print(f"\nProcessing tracks {batch_start + 1}-{batch_end} of {total_tracks}")
|
||||
|
||||
batch_tracks: list[Track] = []
|
||||
for track in tracks[batch_start:batch_end]:
|
||||
track_number: str = track.index or "N/A"
|
||||
disc_number: str = track.parentIndex if hasattr(track, 'parentIndex') else "N/A"
|
||||
track_title: str = track.title or "Unknown Title"
|
||||
|
||||
# Wrap potentially blocking calls
|
||||
track_artist: str = track.originalTitle or (
|
||||
await loop.run_in_executor(executor, lambda t=track: t.artist().title if t.artist() else "Unknown Artist")
|
||||
)
|
||||
album_title: str = await loop.run_in_executor(executor, lambda t=track: t.album().title if t.album() else "Unknown Album")
|
||||
album_artist: str = await loop.run_in_executor(executor, lambda t=track:
|
||||
t.album().artist().title if t.album() and t.album().artist() else "Unknown Artist")
|
||||
year: str = await loop.run_in_executor(executor, lambda t=track: t.album().year if t.album() else "N/A")
|
||||
|
||||
genres: list = track.genres if hasattr(track, 'genres') and track.genres else []
|
||||
genre_list: list[str] = [genre.tag for genre in genres] if genres else []
|
||||
date_added: str = track.addedAt.strftime('%Y-%m-%d') if track.addedAt else "N/A"
|
||||
last_played: str = track.lastViewedAt.strftime('%Y-%m-%d') if track.lastViewedAt else "Never"
|
||||
play_count: int = track.viewCount if track.viewCount is not None else 0
|
||||
rating: str = track.userRating if track.userRating is not None else "Not Rated"
|
||||
|
||||
# Create Track instance and append to batch list
|
||||
track_data: Track = Track(
|
||||
track_number=track_number,
|
||||
disc_number=disc_number,
|
||||
title=track_title,
|
||||
track_artist=track_artist,
|
||||
album_title=album_title,
|
||||
album_artist=album_artist,
|
||||
year=year,
|
||||
genres=genre_list,
|
||||
date_added=date_added,
|
||||
last_played=last_played,
|
||||
play_count=play_count,
|
||||
rating=rating
|
||||
)
|
||||
batch_tracks.append(track_data)
|
||||
|
||||
return batch_tracks
|
||||
|
||||
async def get_music_tracks(plex: PlexServer, batch_size: int = 1000) -> list[Track]:
|
||||
"""Retrieve all music tracks from Plex server in batches and return combined list.
|
||||
|
||||
Args:
|
||||
plex: Connected PlexServer instance.
|
||||
batch_size: Number of tracks to process per batch. Defaults to 1000.
|
||||
|
||||
Returns:
|
||||
Combined list of all processed Track objects.
|
||||
|
||||
Raises:
|
||||
Exception: If there's an error retrieving tracks from the Plex server.
|
||||
"""
|
||||
try:
|
||||
# Get all music libraries
|
||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||
sections: list = await loop.run_in_executor(executor, lambda: [s for s in plex.library.sections() if s.type == 'artist'])
|
||||
|
||||
if not sections:
|
||||
print("No music libraries found!")
|
||||
return []
|
||||
|
||||
# Collect all tracks from all music sections
|
||||
all_tracks: list = []
|
||||
for section in sections:
|
||||
print(f"\nCollecting tracks from library: {section.title}")
|
||||
tracks: list = await loop.run_in_executor(executor, section.search, {'libtype': 'track'})
|
||||
all_tracks.extend(tracks)
|
||||
|
||||
total_tracks: int = len(all_tracks)
|
||||
print(f"\nFound {total_tracks} tracks across all libraries")
|
||||
print("-" * 50)
|
||||
|
||||
if total_tracks == 0:
|
||||
print("No tracks found!")
|
||||
return []
|
||||
|
||||
# Create tasks for each batch
|
||||
tasks: list[asyncio.Task] = []
|
||||
for batch_start in range(0, total_tracks, batch_size):
|
||||
batch_end: int = min(batch_start + batch_size, total_tracks)
|
||||
tasks.append(process_track_batch(all_tracks, batch_start, batch_end, total_tracks))
|
||||
|
||||
# Run all batch tasks concurrently and gather results
|
||||
batch_results: list[list[Track]] = await asyncio.gather(*tasks)
|
||||
|
||||
# Efficiently combine all tracks from batches
|
||||
combined_tracks: list[Track] = []
|
||||
for batch in batch_results:
|
||||
combined_tracks.extend(batch)
|
||||
|
||||
# Print all tracks
|
||||
for track in combined_tracks:
|
||||
print(track)
|
||||
|
||||
return combined_tracks
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error retrieving music tracks: {e}")
|
||||
return []
|
||||
|
||||
async def main() -> None:
|
||||
"""Main entry point for the Plex Playlist Track Lister.
|
||||
|
||||
Parses command-line arguments, manages configuration, and retrieves tracks.
|
||||
"""
|
||||
parser: argparse.ArgumentParser = argparse.ArgumentParser(description="Plex Playlist Track Lister")
|
||||
parser.add_argument('--login', action='store_true', help="Force login and update credentials")
|
||||
parser.add_argument('--batch-size', type=int, default=1000,
|
||||
help="Number of tracks to process per batch (default: 1000)")
|
||||
args: argparse.Namespace = parser.parse_args()
|
||||
|
||||
# Try to load existing configuration
|
||||
baseurl: str | None
|
||||
token: str | None
|
||||
baseurl, token = await load_config()
|
||||
|
||||
# If no config exists or login is forced
|
||||
if args.login or not (baseurl and token):
|
||||
baseurl = input("Enter Plex server URL (e.g., http://192.168.1.100:32400): ").strip()
|
||||
token = input("Enter Plex token: ").strip()
|
||||
|
||||
# Test the connection
|
||||
plex: PlexServer | None = await connect_to_plex(baseurl, token)
|
||||
if plex:
|
||||
await save_config(baseurl, token)
|
||||
else:
|
||||
print("Failed to connect with provided credentials. Exiting.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
plex = await connect_to_plex(baseurl, token)
|
||||
if not plex:
|
||||
print("Stored credentials failed. Please use --login to update them.")
|
||||
sys.exit(1)
|
||||
|
||||
# Get and display music tracks with specified batch size
|
||||
tracks: list[Track] = await get_music_tracks(plex, batch_size=args.batch_size)
|
||||
print(f"\nTotal tracks retrieved: {len(tracks)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test cases for the __main__ module."""
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from playlist import __main__
|
||||
|
||||
|
||||
Reference in New Issue
Block a user