Compare commits

...

19 Commits

Author SHA1 Message Date
0bbf7a405e use album id when fetching audio image url 2025-12-11 12:22:29 +01:00
6452cb7f41 use original title for movies 2025-12-10 22:50:29 +01:00
740c892946 show only premiere year for movies 2025-12-10 18:15:19 +01:00
e9827723f4 extract jellyfin utilities 2025-12-10 16:43:24 +01:00
89209fa675 extract image utils 2025-12-10 16:22:19 +01:00
f8619aa66c extract playback utils 2025-12-10 16:19:49 +01:00
b7ff56ced2 add jellyfin utils folder 2025-12-10 16:15:12 +01:00
946b1605e0 improve typing 2025-12-10 11:51:59 +01:00
32e4a76cd1 configure client in utils method 2025-12-10 11:50:47 +01:00
a5659d90e8 extract jellyfin utils 2025-12-10 11:46:04 +01:00
906b07e20c improve start/end typing 2025-12-10 04:23:08 +01:00
d4febbc3b2 validate jellyfin image url 2025-12-10 04:10:06 +01:00
eb69650423 use strenum class 2025-12-10 04:00:39 +01:00
0c242f15f4 replace jellyfin utils with class method 2025-12-10 03:59:29 +01:00
5ca3a38beb dynamically set auth.ssl parameter 2025-12-10 03:55:17 +01:00
090b96bb4e add documentation 2025-12-10 03:52:03 +01:00
4a33281ca4 improve typing 2025-12-10 03:41:11 +01:00
d0fcb3e57c handle paused state 2025-12-10 03:32:10 +01:00
4d76ef02d9 remove rich presence caching 2025-12-10 03:28:03 +01:00
15 changed files with 410 additions and 171 deletions

View File

@@ -4,3 +4,8 @@
- Updated Jellyfin image fetching logic to use `ParentId` for episodes and music tracks to ensure correct artwork is displayed in Discord Rich Presence - Updated Jellyfin image fetching logic to use `ParentId` for episodes and music tracks to ensure correct artwork is displayed in Discord Rich Presence
- Added `coloredlogs` dependency for improved logging output - Added `coloredlogs` dependency for improved logging output
- 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
- 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,5 +1,6 @@
from pydantic import BaseModel from pydantic import BaseModel, NonNegativeInt
from pypresence.types import ActivityType from pypresence.types import ActivityType
from typing import Optional
class DiscordRPCUpdatePayload(BaseModel): class DiscordRPCUpdatePayload(BaseModel):
@@ -8,6 +9,6 @@ class DiscordRPCUpdatePayload(BaseModel):
subtitle: str subtitle: str
image_url: str image_url: str
details: str details: str
start: int start: Optional[NonNegativeInt]
end: int end: Optional[NonNegativeInt]
activity_type: ActivityType activity_type: ActivityType

View File

@@ -5,21 +5,34 @@ import logging
class DiscordRPC: class DiscordRPC:
def __init__(self): """
self.logger = logging.getLogger('DiscordRPC') 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):
"""
Initializes the Discord RPC client and connects to Discord via IPC.
"""
self.logger.info("Connecting to Discord RPC...") self.logger.info("Connecting to Discord RPC...")
self.last_update_id = None
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):
if self.last_update_id == payload.id: """
self.logger.debug("No update needed for Discord RPC presence.") Updates the Discord RPC presence with the provided payload.
return
Args:
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,
@@ -29,10 +42,15 @@ class DiscordRPC:
start=payload.start, start=payload.start,
end=payload.end end=payload.end
) )
self.last_update_id = payload.id
self.logger.info("Discord RPC presence updated.") self.logger.info("Discord RPC presence updated.")
def clear(self): def clear(self):
"""
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,31 +1,47 @@
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 JellyfinMediaItem,
from datetime import datetime JellyfinMediaType,
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.
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):
machine_name = os.uname().nodename """
unique_id = get_mac_address(hostname="localhost") Initializes the Jellyfin API client and authenticates with the server.
"""
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):
"""
Authenticates with the Jellyfin server.
"""
self.logger.info("Authenticating with Jellyfin server...") self.logger.info("Authenticating with Jellyfin server...")
self.client.auth.connect_to_address(settings.jellyfin_server_url) self.client.auth.connect_to_address(settings.jellyfin_server_url)
self.client.auth.login( self.client.auth.login(
@@ -36,7 +52,13 @@ 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 media information from the Jellyfin server.
Returns:
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()
@@ -59,93 +81,20 @@ 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:
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.
Args:
item (dict): The Jellyfin media item dictionary.
Returns:
JellyfinMediaItem: The converted JellyfinMediaItem model.
"""
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[int, int]:
play_state = media.get('PlayState')
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:
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
(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={
'artist': item.get('AlbumArtist'),
'album': f"{
item.get('Album')} ({premiere_year})" if premiere_date else item.get('Album')})
def to_movie_model(self, item: dict) -> JellyfinMediaItem:
media_id = item.get('Id')
premiere_date = item.get('PremiereDate')
(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={
'date': datetime.fromisoformat(premiere_date).strftime('%d/%m/%Y') if premiere_date else None
}
)
def to_episode_model(self, item: dict) -> JellyfinMediaItem:
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')
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={
'subtitle': subtitle,
}
)

View File

@@ -1,18 +1,78 @@
from pydantic import BaseModel from pydantic import BaseModel, HttpUrl, NonNegativeInt
from enum import Enum from enum import StrEnum
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'
class JellyfinMusicMediaMetadata(BaseModel):
artist: Optional[str]
album: Optional[str]
class JellyfinMovieMediaMetadata(BaseModel):
year: Optional[int]
class JellyfinEpisodeMediaMetadata(BaseModel):
subtitle: str
class JellyfinMediaItem(BaseModel): class JellyfinMediaItem(BaseModel):
id: str id: str
name: str name: str
type: JellyfinMediaType type: JellyfinMediaType
image_url: str image_url: HttpUrl
start: int start: Optional[NonNegativeInt]
end: int end: Optional[NonNegativeInt]
metadata: dict metadata: Union[JellyfinMusicMediaMetadata,
JellyfinMovieMediaMetadata,
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,44 +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:
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.get(
'artist',
'Unknown Artist')}",
image_url=media_item.image_url,
details=media_item.metadata.get(
'album',
'Unknown 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.get('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.get('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)

23
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
coloredlogs.install(
level=logging.INFO,
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
discordRPC = DiscordRPC()
jellyfinApiClient = JellyfinApiClient()
def main(): def main():
coloredlogs.install(
level=logging.INFO,
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
discord_rpc = DiscordRPC()
jellyfin_api_client = JellyfinApiClient()
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

@@ -3,6 +3,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
"""
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")