Compare commits
2 Commits
grok-assis
...
chatgpt-as
| Author | SHA1 | Date | |
|---|---|---|---|
|
23a42c065d
|
|||
|
6be20af94f
|
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"_output_dir": "/home/darkhelm/Projects/DarkHelm.org/plex-playlist/temp",
|
||||
"_checkout": "2022.6.3",
|
||||
"_output_dir": "/config/workspace/DarkHelm.org",
|
||||
"_repo_dir": "/config/.cookiecutters/cookiecutter-hypermodern-python",
|
||||
"_template": "gh:cjolowicz/cookiecutter-hypermodern-python",
|
||||
"author": "Cliff Hill",
|
||||
"copyright_year": "2025",
|
||||
"development_status": "Development Status :: 2 - Pre-Alpha",
|
||||
"copyright_year": "2024",
|
||||
"development_status": "Development Status :: 1 - Planning",
|
||||
"email": "xlorep@darkhelm.org",
|
||||
"friendly_name": "Plex Playlist",
|
||||
"github_user": "xlorepdarkhelm",
|
||||
"license": "Apache-2.0",
|
||||
"package_name": "playlist",
|
||||
"license": "MIT",
|
||||
"package_name": "xdh.playlist",
|
||||
"project_name": "plex-playlist",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
|
||||
1
.flake8
1
.flake8
@@ -5,5 +5,6 @@ max-line-length = 80
|
||||
max-complexity = 10
|
||||
docstring-convention = google
|
||||
per-file-ignores = tests/*:S101
|
||||
exclude = noxfile.py
|
||||
rst-roles = class,const,func,meth,mod,ref
|
||||
rst-directives = deprecated
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@
|
||||
/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 [Apache 2.0 license] and
|
||||
This project is open-source under the [MIT 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]
|
||||
|
||||
[apache 2.0 license]: https://opensource.org/licenses/Apache-2.0
|
||||
[mit license]: https://opensource.org/licenses/MIT
|
||||
[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,217 +1,21 @@
|
||||
Copyright 2025 Cliff Hill
|
||||
MIT License
|
||||
|
||||
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
|
||||
Copyright © 2024 Cliff Hill
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
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:
|
||||
|
||||
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 above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of 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.
|
||||
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.
|
||||
|
||||
@@ -48,7 +48,7 @@ To learn more, see the [Contributor Guide].
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the terms of the [Apache 2.0 license][license],
|
||||
Distributed under the terms of the [MIT license][license],
|
||||
_Plex Playlist_ is free and open source software.
|
||||
|
||||
## Issues
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Sphinx configuration."""
|
||||
|
||||
project = "Plex Playlist"
|
||||
author = "Cliff Hill"
|
||||
copyright = "2025, Cliff Hill"
|
||||
copyright = "2024, Cliff Hill"
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.napoleon",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Reference
|
||||
|
||||
## playlist
|
||||
## xdh.playlist
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: playlist
|
||||
.. automodule:: xdh.playlist
|
||||
:members:
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Usage
|
||||
|
||||
```{eval-rst}
|
||||
.. click:: playlist.__main__:main
|
||||
.. click:: xdh.playlist.__main__:main
|
||||
:prog: plex-playlist
|
||||
:nested: full
|
||||
```
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Nox sessions."""
|
||||
|
||||
# noqa
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -22,7 +24,7 @@ except ImportError:
|
||||
raise SystemExit(dedent(message)) from None
|
||||
|
||||
|
||||
package = "playlist"
|
||||
package = "xdh.playlist"
|
||||
python_versions = ["3.10", "3.9", "3.8", "3.7"]
|
||||
nox.needs_version = ">= 2021.6.6"
|
||||
nox.options.sessions = (
|
||||
|
||||
2641
poetry.lock
generated
2641
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,24 +3,26 @@ name = "plex-playlist"
|
||||
version = "0.0.0"
|
||||
description = "Plex Playlist"
|
||||
authors = ["Cliff Hill <xlorep@darkhelm.org>"]
|
||||
license = "Apache-2.0"
|
||||
license = "MIT"
|
||||
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 = "src" },
|
||||
{ include = "xdh", from = "src" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 2 - Pre-Alpha",
|
||||
"Development Status :: 1 - Planning",
|
||||
]
|
||||
|
||||
[tool.poetry.urls]
|
||||
Changelog = "https://github.com/xlorepdarkhelm/plex-playlist/releases"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.7"
|
||||
python = "^3.12"
|
||||
click = ">=8.0.1"
|
||||
plexapi = ">=4.7.2"
|
||||
python-dotenv = ">=0.19.2"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
Pygments = ">=2.10.0"
|
||||
@@ -39,6 +41,7 @@ pep8-naming = ">=0.12.1"
|
||||
pre-commit = ">=2.16.0"
|
||||
pre-commit-hooks = ">=4.1.0"
|
||||
pytest = ">=6.2.5"
|
||||
pytest-asyncio = ">=0.16.0"
|
||||
pyupgrade = ">=2.29.1"
|
||||
safety = ">=1.10.3"
|
||||
sphinx = ">=4.3.2"
|
||||
@@ -49,7 +52,10 @@ xdoctest = {extras = ["colors"], version = ">=0.15.10"}
|
||||
myst-parser = {version = ">=0.16.1"}
|
||||
|
||||
[tool.poetry.scripts]
|
||||
plex-playlist = "playlist.__main__:main"
|
||||
plex-playlist = "xdh.playlist.__main__:main"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ipython = "^8.27.0"
|
||||
|
||||
[tool.coverage.paths]
|
||||
source = ["src", "*/site-packages"]
|
||||
@@ -57,7 +63,7 @@ tests = ["tests", "*/tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["playlist", "tests"]
|
||||
source = ["xdh.playlist", "tests"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Plex Playlist."""
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Command-line interface."""
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.version_option()
|
||||
def main() -> None:
|
||||
"""Plex Playlist."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(prog_name="plex-playlist") # pragma: no cover
|
||||
@@ -1,284 +0,0 @@
|
||||
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())
|
||||
24
src/xdh/playlist/__init__.py
Normal file
24
src/xdh/playlist/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Plex Playlist."""
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
|
||||
class _Env:
|
||||
def __init__(self, filename: str = ".env") -> None:
|
||||
self.__env = dotenv_values(filename)
|
||||
|
||||
def __getattr__(self, name: str) -> str:
|
||||
try:
|
||||
return self.__env[name]
|
||||
except KeyError as e:
|
||||
try:
|
||||
return os.environ[name]
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
f"{name} is not a defined environment variable."
|
||||
) from e
|
||||
|
||||
|
||||
env = _Env()
|
||||
25
src/xdh/playlist/__main__.py
Normal file
25
src/xdh/playlist/__main__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Command-line interface."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
|
||||
from xdh.playlist.plex import PlexTrackFetcher
|
||||
|
||||
|
||||
@click.command()
|
||||
def fetch_tracks(batch_size: int):
|
||||
"""Fetch tracks from Plex server in batches."""
|
||||
click.echo(f"Fetching Plex tracks in batches of {batch_size}...")
|
||||
|
||||
plex_fetcher = PlexTrackFetcher(batch_size)
|
||||
|
||||
async def run():
|
||||
async for track in plex_fetcher.fetch_tracks():
|
||||
click.echo(track)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_tracks()
|
||||
62
src/xdh/playlist/plex.py
Normal file
62
src/xdh/playlist/plex.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Plex API calls."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
from xdh.playlist import env
|
||||
|
||||
|
||||
class PlexTrackFetcher:
|
||||
"""Fetches tracks from Plex in batches asynchronously."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the track fetcher."""
|
||||
server = PlexServer(env.PLEX_URL, env.PLEX_TOKEN)
|
||||
self.music_library = server.library.section("Music")
|
||||
|
||||
async def fetch_tracks(self) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""Async generator that fetches and yields tracks from Plex in batches."""
|
||||
total_tracks = self.music_library.totalSize
|
||||
|
||||
with ProcessPoolExecutor() as executor:
|
||||
loop = asyncio.get_event_loop()
|
||||
futures = [
|
||||
loop.run_in_executor(executor, self._fetch_batch, i)
|
||||
for i in range(0, total_tracks, env.BATCH_SIZE)
|
||||
]
|
||||
for future in asyncio.as_completed(futures):
|
||||
result_batch = await future
|
||||
for track in result_batch:
|
||||
yield track
|
||||
|
||||
def _fetch_batch(self, start: int) -> list[dict[str, Any]]:
|
||||
"""Fetch a batch of tracks."""
|
||||
batch = self.music_library.searchTracks(
|
||||
max_results=env.BATCH_SIZE,
|
||||
container_start=start,
|
||||
container_size=env.BATCH_SIZE,
|
||||
)
|
||||
|
||||
result = [
|
||||
{
|
||||
"plex_id": track.ratingKey,
|
||||
"title": track.title,
|
||||
"album_name": track.album().title if track.album() else None,
|
||||
"artist": track.originalTitle,
|
||||
"album_artist": (
|
||||
track.album().artist().title if track.album() else None
|
||||
),
|
||||
"date_last_played": track.lastViewedAt,
|
||||
"date_added": track.addedAt,
|
||||
"play_count": track.viewCount,
|
||||
"track_number": track.index,
|
||||
"disc_number": track.disc,
|
||||
"rating": track.userRating,
|
||||
}
|
||||
for track in batch
|
||||
]
|
||||
return result
|
||||
@@ -1 +1 @@
|
||||
"""Test suite for the playlist package."""
|
||||
"""Test suite for the xdh.playlist package."""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Test cases for the __main__ module."""
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from playlist import __main__
|
||||
from xdh.playlist import __main__
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Reference in New Issue
Block a user