Initial file written.
Some checks failed
Tests / tests 3.10 / macos-latest (push) Has been cancelled
Tests / docs-build 3.10 / ubuntu-latest (push) Has been cancelled
Tests / mypy 3.10 / ubuntu-latest (push) Has been cancelled
Tests / pre-commit 3.10 / ubuntu-latest (push) Has been cancelled
Tests / safety 3.10 / ubuntu-latest (push) Has been cancelled
Tests / tests 3.10 / ubuntu-latest (push) Has been cancelled
Tests / typeguard 3.10 / ubuntu-latest (push) Has been cancelled
Tests / xdoctest 3.10 / ubuntu-latest (push) Has been cancelled
Tests / mypy 3.7 / ubuntu-latest (push) Has been cancelled
Tests / tests 3.7 / ubuntu-latest (push) Has been cancelled
Tests / mypy 3.8 / ubuntu-latest (push) Has been cancelled
Tests / tests 3.8 / ubuntu-latest (push) Has been cancelled
Tests / mypy 3.9 / ubuntu-latest (push) Has been cancelled
Tests / tests 3.9 / ubuntu-latest (push) Has been cancelled
Tests / tests 3.10 / windows-latest (push) Has been cancelled
Tests / coverage (push) Has been cancelled

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-03-11 14:16:36 -04:00
parent 8f709877bc
commit 3b1be1c4b1

284
src/playlist/loader.py Normal file
View 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())