Compare commits

..

16 Commits

15 changed files with 351 additions and 222 deletions

View File

@@ -6,3 +6,6 @@
- Added a formatting script (`scripts/format.sh`) that uses `autopep8` to automatically format the codebase for better readability and consistency - Added a formatting script (`scripts/format.sh`) that uses `autopep8` to automatically format the codebase for better readability and consistency
- Removed caching of last fetched items to ensure the most up-to-date information is always displayed in Discord Rich Presence - Removed caching of last fetched items to ensure the most up-to-date information is always displayed in Discord Rich Presence
- Handle paused state correctly by checking the `PlayState` property from Jellyfin and updating Discord Rich Presence accordingly - Handle paused state correctly by checking the `PlayState` property from Jellyfin and updating Discord Rich Presence accordingly
- Set the `auth.ssl` configuration option in Jellyfin Client dynamically based on the `jellyfin_server_url`
- Removed `to_rpc_payload` function in favor of a class method within the `JellyfinMediaItem` class for better encapsulation and organization of code
- Added major improvements to typing and type hints throughout the codebase for better code clarity and maintainability

5
changelog/0.1.2.md Normal file
View File

@@ -0,0 +1,5 @@
# 0.1.2
- Extracted utility functions from JellyfinApiClient to utils modules
- Keep client configuration in Settings class
- Use `OriginalTitle` instead of `Name` for media items when available to provide more accurate titles in Discord Rich Presence

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, NonNegativeInt
from pypresence.types import ActivityType from pypresence.types import ActivityType
from typing import Optional from typing import Optional
@@ -9,6 +9,6 @@ class DiscordRPCUpdatePayload(BaseModel):
subtitle: str subtitle: str
image_url: str image_url: str
details: str details: str
start: Optional[int] start: Optional[NonNegativeInt]
end: Optional[int] end: Optional[NonNegativeInt]
activity_type: ActivityType activity_type: ActivityType

View File

@@ -7,17 +7,21 @@ import logging
class DiscordRPC: class DiscordRPC:
""" """
Client for interacting with Discord Rich Presence (RPC). Client for interacting with Discord Rich Presence (RPC).
Attributes:
logger (logging.Logger): Logger instance for logging messages.
""" """
logger: logging.Logger = logging.getLogger('DiscordRPC')
def __init__(self): def __init__(self):
""" """
Initializes the Discord RPC client and connects to Discord via IPC. Initializes the Discord RPC client and connects to Discord via IPC.
""" """
self.logger = logging.getLogger('DiscordRPC')
self.logger.info("Connecting to Discord RPC...") self.logger.info("Connecting to Discord RPC...")
self.rpc = Presence(settings.discord_app_id) self.rpc = Presence(settings.discord_app_id)
self.rpc.connect() self.rpc.connect()
self.logger.info("Connected to Discord RPC.") self.logger.info("Connected to Discord RPC.")
def update(self, payload: DiscordRPCUpdatePayload): def update(self, payload: DiscordRPCUpdatePayload):
@@ -28,6 +32,7 @@ class DiscordRPC:
payload (DiscordRPCUpdatePayload): The payload containing presence information. payload (DiscordRPCUpdatePayload): The payload containing presence information.
""" """
self.logger.info("Updating Discord RPC presence...") self.logger.info("Updating Discord RPC presence...")
self.rpc.update( self.rpc.update(
activity_type=payload.activity_type, activity_type=payload.activity_type,
details=payload.title, details=payload.title,
@@ -37,6 +42,7 @@ class DiscordRPC:
start=payload.start, start=payload.start,
end=payload.end end=payload.end
) )
self.logger.info("Discord RPC presence updated.") self.logger.info("Discord RPC presence updated.")
def clear(self): def clear(self):
@@ -44,5 +50,7 @@ class DiscordRPC:
Clears the Discord RPC presence. Clears the Discord RPC presence.
""" """
self.logger.info("Clearing Discord RPC presence...") self.logger.info("Clearing Discord RPC presence...")
self.rpc.clear() self.rpc.clear()
self.logger.info("Discord RPC presence cleared.") self.logger.info("Discord RPC presence cleared.")

View File

@@ -1,36 +1,41 @@
from settings import settings from settings import settings
from jellyfin_apiclient_python import JellyfinClient from jellyfin_apiclient_python import JellyfinClient
from getmac import get_mac_address from jellyfin.models import (
from jellyfin.models import JellyfinMediaItem, JellyfinMediaType, JellyfinMusicMediaMetadata, JellyfinMovieMediaMetadata, JellyfinEpisodeMediaMetadata JellyfinMediaItem,
from datetime import datetime JellyfinMediaType,
from typing import Optional, Tuple JellyfinMusicMediaMetadata,
JellyfinMovieMediaMetadata,
JellyfinEpisodeMediaMetadata
)
from jellyfin.utils.config import configure_client
from jellyfin.utils.image import get_image_url
from jellyfin.utils.models import episode, movie, audio
from typing import Optional
import logging import logging
import time import time
import os
class JellyfinApiClient: class JellyfinApiClient:
""" """
Client for interacting with the Jellyfin server API. Client for interacting with the Jellyfin server API.
Attributes:
last_auth_time (Optional[float]): Timestamp of the last authentication.
logger (logging.Logger): Logger instance for logging messages.
""" """
last_auth_time: Optional[float] = None
logger: logging.Logger = logging.getLogger('JellyfinApiClient')
def __init__(self): def __init__(self):
""" """
Initializes the Jellyfin API client and authenticates with the server. Initializes the Jellyfin API client and authenticates with the server.
""" """
machine_name = os.uname().nodename
unique_id = get_mac_address(hostname="localhost")
self.logger = logging.getLogger('JellyfinApiClient')
self.logger.info("Connecting to Jellyfin server...") self.logger.info("Connecting to Jellyfin server...")
self.client = JellyfinClient() self.client = JellyfinClient()
self.client.config.app('jellydisc', '0.1.1', machine_name, unique_id) configure_client(self.client)
self.client.config.data['auth.ssl'] = True
self.last_auth_time = None
self.authenticate() self.authenticate()
self.logger.info("Connected to Jellyfin server.") self.logger.info("Connected to Jellyfin server.")
def authenticate(self): def authenticate(self):
@@ -47,12 +52,12 @@ class JellyfinApiClient:
self.last_auth_time = time.time() self.last_auth_time = time.time()
self.logger.info("Authenticated with Jellyfin server.") self.logger.info("Authenticated with Jellyfin server.")
def get_current_playback(self) -> JellyfinMediaItem | None: def get_current_media(self) -> Optional[JellyfinMediaItem]:
""" """
Fetches the current playback information from the Jellyfin server. Fetches the current media information from the Jellyfin server.
Returns: Returns:
JellyfinMediaItem | None: The current playback media item or None if no active playback is found. Optional[JellyfinMediaItem]: The current playback media item or None if no active playback is found.
""" """
if time.time() - self.last_auth_time > settings.jellyfin_auth_timeout: if time.time() - self.last_auth_time > settings.jellyfin_auth_timeout:
self.authenticate() self.authenticate()
@@ -76,18 +81,6 @@ class JellyfinApiClient:
self.logger.info("No active playback found.") self.logger.info("No active playback found.")
return None return None
def get_image_url(self, media_id: str) -> str:
"""
Constructs the image URL for a given media item.
Args:
media_id (str): The ID of the media item.
Returns:
str: The constructed image URL.
"""
server_address = settings.jellyfin_server_url.rstrip('/')
return f"{server_address}/Items/{media_id}/Images/Primary?maxWidth=300&maxHeight=300"
def to_model(self, item: dict) -> JellyfinMediaItem: def to_model(self, item: dict) -> JellyfinMediaItem:
""" """
Converts a Jellyfin media item dictionary to a JellyfinMediaItem model. Converts a Jellyfin media item dictionary to a JellyfinMediaItem model.
@@ -100,126 +93,8 @@ class JellyfinApiClient:
media_type = item.get('Type') media_type = item.get('Type')
if media_type == 'Audio': if media_type == 'Audio':
return self.to_music_model(item) return audio.to_media_item(item)
elif media_type == 'Episode': elif media_type == 'Episode':
return self.to_episode_model(item) return episode.to_media_item(item, self.client)
elif media_type == 'Movie': elif media_type == 'Movie':
return self.to_movie_model(item) return movie.to_media_item(item)
def get_playback_info(
self, media: dict) -> Tuple[Optional[int], Optional[int]]:
"""
Extracts playback start and end timestamps from a media item.
Args:
media (dict): The Jellyfin media item dictionary.
Returns:
Tuple[Optional[int], Optional[int]]: A tuple containing the start and end timestamps in seconds, or (None, None) if the media is paused.
"""
play_state = media.get('PlayState')
is_paused = play_state.get('IsPaused')
if is_paused:
return (None, None)
runtime_ticks = media.get('RunTimeTicks')
total_runtime_seconds = runtime_ticks // 10_000_000
playback_position_ticks = play_state.get('PositionTicks')
playback_position_seconds = playback_position_ticks // 10_000_000
start = int(datetime.now().timestamp()) - playback_position_seconds
end = start + total_runtime_seconds
return (start, end)
def to_music_model(self, item: dict) -> JellyfinMediaItem:
"""
Converts a Jellyfin music media item dictionary to a JellyfinMediaItem model.
Args:
item (dict): The Jellyfin music media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
media_id = item.get('Id')
parent_id = item.get('ParentId')
premiere_date = item.get('PremiereDate')
premiere_year = datetime.fromisoformat(
premiere_date).year if premiere_date else None
metadata = JellyfinMusicMediaMetadata(
artist=item.get('AlbumArtist'), album=f"{
item.get('Album')} ({premiere_year})" if premiere_date else item.get('Album'))
(start, end) = self.get_playback_info(item)
return JellyfinMediaItem(
id=media_id,
name=item.get('Name'),
type=JellyfinMediaType.AUDIO,
image_url=self.get_image_url(parent_id),
start=start,
end=end,
metadata=metadata
)
def to_movie_model(self, item: dict) -> JellyfinMediaItem:
"""
Converts a Jellyfin movie media item dictionary to a JellyfinMediaItem model.
Args:
item (dict): The Jellyfin movie media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
media_id = item.get('Id')
premiere_date = item.get('PremiereDate')
metadata = JellyfinMovieMediaMetadata(date=datetime.fromisoformat(
premiere_date).strftime('%d/%m/%Y') if premiere_date else None)
(start, end) = self.get_playback_info(item)
return JellyfinMediaItem(
id=media_id,
name=item.get('Name'),
type=JellyfinMediaType.MOVIE,
image_url=self.get_image_url(media_id),
start=start,
end=end,
metadata=metadata
)
def to_episode_model(self, item: dict) -> JellyfinMediaItem:
"""
Converts a Jellyfin episode media item dictionary to a JellyfinMediaItem model.
Args:
item (dict): The Jellyfin episode media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
media_id = item.get('Id')
parent = self.client.jellyfin.get_item(item.get('ParentId'))
parent_id = parent.get('ParentId')
series_name = item.get('SeriesName')
season_number = item.get('ParentIndexNumber')
episode_number = item.get('IndexNumber')
metadata = JellyfinEpisodeMediaMetadata(
subtitle=f"S{
season_number:02}E{
episode_number:02} of {series_name}")
(start, end) = self.get_playback_info(item)
return JellyfinMediaItem(
id=media_id,
name=item.get('Name'),
type=JellyfinMediaType.EPISODE,
image_url=self.get_image_url(parent_id),
start=start,
end=end,
metadata=metadata
)

View File

@@ -1,9 +1,11 @@
from pydantic import BaseModel from pydantic import BaseModel, HttpUrl, NonNegativeInt
from enum import Enum from enum import StrEnum
from typing import Optional, Union from typing import Optional, Union
from discord.models import DiscordRPCUpdatePayload
from pypresence.types import ActivityType
class JellyfinMediaType(str, Enum): class JellyfinMediaType(StrEnum):
AUDIO = 'Audio' AUDIO = 'Audio'
MOVIE = 'Movie' MOVIE = 'Movie'
EPISODE = 'Episode' EPISODE = 'Episode'
@@ -15,7 +17,7 @@ class JellyfinMusicMediaMetadata(BaseModel):
class JellyfinMovieMediaMetadata(BaseModel): class JellyfinMovieMediaMetadata(BaseModel):
date: Optional[str] year: Optional[int]
class JellyfinEpisodeMediaMetadata(BaseModel): class JellyfinEpisodeMediaMetadata(BaseModel):
@@ -26,9 +28,51 @@ class JellyfinMediaItem(BaseModel):
id: str id: str
name: str name: str
type: JellyfinMediaType type: JellyfinMediaType
image_url: str image_url: HttpUrl
start: Optional[int] start: Optional[NonNegativeInt]
end: Optional[int] end: Optional[NonNegativeInt]
metadata: Union[JellyfinMusicMediaMetadata, metadata: Union[JellyfinMusicMediaMetadata,
JellyfinMovieMediaMetadata, JellyfinMovieMediaMetadata,
JellyfinEpisodeMediaMetadata] JellyfinEpisodeMediaMetadata]
def to_rpc_payload(self) -> DiscordRPCUpdatePayload:
"""
Converts a JellyfinMediaItem to a DiscordRPCUpdatePayload.
Returns:
DiscordRPCUpdatePayload: The converted Discord RPC update payload.
"""
if self.type == JellyfinMediaType.AUDIO:
return DiscordRPCUpdatePayload(
id=self.id,
title=f"Listening to {
self.name}",
subtitle=f"by {
self.metadata.artist}",
image_url=str(self.image_url),
details=self.metadata.album,
activity_type=ActivityType.LISTENING,
start=self.start,
end=self.end)
elif self.type == JellyfinMediaType.MOVIE:
return DiscordRPCUpdatePayload(
id=self.id,
title=f"Watching {self.name}",
subtitle=str(self.metadata.year),
image_url=str(self.image_url),
details=self.name,
activity_type=ActivityType.WATCHING,
start=self.start,
end=self.end
)
elif self.type == JellyfinMediaType.EPISODE:
return DiscordRPCUpdatePayload(
id=self.id,
title=f"Watching {self.name}",
subtitle=self.metadata.subtitle,
image_url=str(self.image_url),
details=self.name,
activity_type=ActivityType.WATCHING,
start=self.start,
end=self.end
)

View File

@@ -1,48 +0,0 @@
from discord.models import DiscordRPCUpdatePayload
from jellyfin.models import JellyfinMediaItem, JellyfinMediaType
from pypresence.types import ActivityType
def to_rpc_payload(media_item: JellyfinMediaItem) -> DiscordRPCUpdatePayload:
"""
Converts a JellyfinMediaItem to a DiscordRPCUpdatePayload.
Args:
media_item (JellyfinMediaItem): The Jellyfin media item to convert.
Returns:
DiscordRPCUpdatePayload: The converted Discord RPC update payload.
"""
if media_item.type == JellyfinMediaType.AUDIO:
return DiscordRPCUpdatePayload(
id=media_item.id,
title=f"Listening to {
media_item.name}",
subtitle=f"by {
media_item.metadata.artist}",
image_url=media_item.image_url,
details=media_item.metadata.album,
activity_type=ActivityType.LISTENING,
start=media_item.start,
end=media_item.end)
elif media_item.type == JellyfinMediaType.MOVIE:
return DiscordRPCUpdatePayload(
id=media_item.id,
title=f"Watching {media_item.name}",
subtitle=media_item.metadata.date,
image_url=media_item.image_url,
details=media_item.name,
activity_type=ActivityType.WATCHING,
start=media_item.start,
end=media_item.end
)
elif media_item.type == JellyfinMediaType.EPISODE:
return DiscordRPCUpdatePayload(
id=media_item.id,
title=f"Watching {media_item.name}",
subtitle=media_item.metadata.subtitle,
image_url=media_item.image_url,
details=media_item.name,
activity_type=ActivityType.WATCHING,
start=media_item.start,
end=media_item.end
)

41
jellyfin/utils/config.py Normal file
View File

@@ -0,0 +1,41 @@
from jellyfin_apiclient_python import JellyfinClient
from getmac import get_mac_address
from settings import settings
import os
def configure_client(client: JellyfinClient):
"""
Configures the Jellyfin client with application details and SSL settings.
Args:
client (JellyfinClient): The Jellyfin client to configure.
"""
client.config.app(
settings.app_name,
settings.app_version,
get_machine_name(),
get_unique_id()
)
client.config.data['auth.ssl'] = settings.jellyfin_server_url.startswith(
'https://')
def get_machine_name() -> str:
"""
Retrieves the machine name of the current host.
Returns:
str: The machine name.
"""
return os.uname().nodename
def get_unique_id() -> str:
"""
Retrieves the MAC address of the localhost as a unique identifier.
Returns:
str: The MAC address.
"""
return get_mac_address(hostname="localhost")

14
jellyfin/utils/image.py Normal file
View File

@@ -0,0 +1,14 @@
from settings import settings
def get_image_url(media_id: str) -> str:
"""
Constructs the image URL for a given media item.
Args:
media_id (str): The ID of the media item.
Returns:
str: The constructed image URL.
"""
server_address = settings.jellyfin_server_url.rstrip('/')
return f"{server_address}/Items/{media_id}/Images/Primary?maxWidth=300&maxHeight=300"

View File

@@ -0,0 +1,53 @@
from jellyfin.models import (
JellyfinMediaItem,
JellyfinMediaType,
JellyfinMusicMediaMetadata
)
from jellyfin.utils.playback import get_current_playback
from jellyfin.utils.image import get_image_url
from datetime import datetime
def to_media_item(
item: dict
) -> JellyfinMediaItem:
"""
Converts a Jellyfin audio media item dictionary to a JellyfinMediaItem.
Args:
item (dict): The Jellyfin music media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
item_id = item.get('Id')
# Get album ID (for image)
album_id = item.get('ParentId')
# Construct album title (with year if available)
premiere_date = item.get('PremiereDate')
premiere_year = None
album = item.get('Album')
if premiere_date:
premiere_year = datetime.fromisoformat(premiere_date).year
album = f"{album} ({premiere_year})"
# Construct metadata
metadata = JellyfinMusicMediaMetadata(
artist=item.get('AlbumArtist'),
album=album
)
# Get playback positions
(start, end) = get_current_playback(item)
return JellyfinMediaItem(
id=item_id,
name=item.get('Name'),
type=JellyfinMediaType.AUDIO,
image_url=get_image_url(album_id),
start=start,
end=end,
metadata=metadata
)

View File

@@ -0,0 +1,52 @@
from jellyfin.models import (
JellyfinMediaItem,
JellyfinMediaType,
JellyfinEpisodeMediaMetadata
)
from jellyfin_apiclient_python import JellyfinClient
from jellyfin.utils.playback import get_current_playback
from jellyfin.utils.image import get_image_url
def to_media_item(
item: dict,
client: JellyfinClient
) -> JellyfinMediaItem:
"""
Converts a Jellyfin episode media item dictionary to a JellyfinMediaItem.
Args:
item (dict): The Jellyfin episode media item dictionary.
client (JellyfinClient): The Jellyfin client instance.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
item_id = item.get('Id')
# Construct SxxExx name
series_name = item.get('SeriesName')
season_number = item.get('ParentIndexNumber')
episode_number = item.get('IndexNumber')
name = f"{series_name} S{season_number:02}E{episode_number:02}"
# Get TV show ID (for image)
season = client.jellyfin.get_item(item.get('ParentId'))
show_id = season.get('ParentId')
# Construct metadata
metadata = JellyfinEpisodeMediaMetadata(
subtitle=item.get('Name'),
)
# Get playback positions
(start, end) = get_current_playback(item)
return JellyfinMediaItem(
id=item_id,
name=name,
type=JellyfinMediaType.EPISODE,
image_url=get_image_url(show_id),
start=start,
end=end,
metadata=metadata
)

View File

@@ -0,0 +1,48 @@
from jellyfin.models import (
JellyfinMediaItem,
JellyfinMediaType,
JellyfinMovieMediaMetadata
)
from jellyfin.utils.playback import get_current_playback
from jellyfin.utils.image import get_image_url
from datetime import datetime
def to_media_item(
item: dict
) -> JellyfinMediaItem:
"""
Converts a Jellyfin movie media item dictionary to a JellyfinMediaItem.
Args:
item (dict): The Jellyfin movie media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
item_id = item.get('Id')
# Get name
name = item.get('OriginalTitle')
if not name:
name = item.get('Name')
# Construct metadata
premiere_date = item.get('PremiereDate')
premiere_year = datetime.fromisoformat(premiere_date).year
metadata = JellyfinMovieMediaMetadata(
year=premiere_year
)
# Get playback positions
(start, end) = get_current_playback(item)
return JellyfinMediaItem(
id=item_id,
name=name,
type=JellyfinMediaType.MOVIE,
image_url=get_image_url(item_id),
start=start,
end=end,
metadata=metadata
)

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Optional, Tuple
def get_current_playback(media: dict) -> Tuple[Optional[int], Optional[int]]:
"""
Extracts playback start and end positions from a Jellyfin media item dictionary.
Args:
media (dict): The Jellyfin media item dictionary.
Returns:
Tuple[Optional[int], Optional[int]]: A tuple containing the start and end positions in seconds, or (None, None) if the media is paused.
"""
play_state = media.get('PlayState', {})
is_paused = play_state.get('IsPaused', False)
if is_paused:
return (None, None)
runtime_ticks = media.get('RunTimeTicks', -1)
playback_position_ticks = play_state.get('PositionTicks', -1)
if runtime_ticks < 0 or playback_position_ticks < 0:
return (None, None)
total_runtime_seconds = runtime_ticks // 10_000_000
playback_position_seconds = playback_position_ticks // 10_000_000
start = int(datetime.now().timestamp()) - playback_position_seconds
end = start + total_runtime_seconds
return (start, end)

17
main.py
View File

@@ -1,33 +1,32 @@
from discord.rpc import DiscordRPC from discord.rpc import DiscordRPC
from jellyfin.api_client import JellyfinApiClient from jellyfin.api_client import JellyfinApiClient
from jellyfin.utils import to_rpc_payload
from settings import settings from settings import settings
import coloredlogs import coloredlogs
import logging import logging
import time import time
def main():
coloredlogs.install( coloredlogs.install(
level=logging.INFO, level=logging.INFO,
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s') fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
discordRPC = DiscordRPC() discord_rpc = DiscordRPC()
jellyfinApiClient = JellyfinApiClient() jellyfin_api_client = JellyfinApiClient()
def main():
while True: while True:
try: try:
media_item = jellyfinApiClient.get_current_playback() media_item = jellyfin_api_client.get_current_media()
if not media_item: if not media_item:
discordRPC.clear() discord_rpc.clear()
time.sleep(settings.poll_interval) time.sleep(settings.poll_interval)
continue continue
discordRPC.update(to_rpc_payload(media_item)) discord_rpc.update(media_item.to_rpc_payload())
time.sleep(settings.poll_interval) time.sleep(settings.poll_interval)
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Shutting down...") logging.info("Shutting down...")
discordRPC.clear() discord_rpc.clear()
break break

View File

@@ -6,6 +6,9 @@ class Settings(BaseSettings):
""" """
Application settings loaded from environment variables or a .env file. Application settings loaded from environment variables or a .env file.
""" """
app_name: str = Field('jellydisc')
app_version: str = Field('0.1.2')
jellyfin_server_url: str = Field(..., env="JELLYFIN_SERVER_URL") jellyfin_server_url: str = Field(..., env="JELLYFIN_SERVER_URL")
jellyfin_username: str = Field(..., env="JELLYFIN_USERNAME") jellyfin_username: str = Field(..., env="JELLYFIN_USERNAME")
jellyfin_password: str = Field(..., env="JELLYFIN_PASSWORD") jellyfin_password: str = Field(..., env="JELLYFIN_PASSWORD")