Compare commits
45 Commits
9d08dda1c3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
0bbf7a405e
|
|||
|
6452cb7f41
|
|||
|
740c892946
|
|||
|
e9827723f4
|
|||
|
89209fa675
|
|||
|
f8619aa66c
|
|||
|
b7ff56ced2
|
|||
|
946b1605e0
|
|||
|
32e4a76cd1
|
|||
|
a5659d90e8
|
|||
|
906b07e20c
|
|||
|
d4febbc3b2
|
|||
|
eb69650423
|
|||
|
0c242f15f4
|
|||
|
5ca3a38beb
|
|||
|
090b96bb4e
|
|||
|
4a33281ca4
|
|||
|
d0fcb3e57c
|
|||
|
4d76ef02d9
|
|||
|
ede88e55a9
|
|||
|
59183e7021
|
|||
|
9242b02957
|
|||
| c3b5d7633a | |||
|
210aa0b85e
|
|||
|
fd2ff8ac6b
|
|||
|
0183d42d55
|
|||
|
d12f2e5a79
|
|||
|
a40d307956
|
|||
|
918b3bae14
|
|||
|
ce9223a71a
|
|||
|
0a31d364d9
|
|||
|
646b1efd8c
|
|||
|
8f29ed7aee
|
|||
|
dc94bb8ba5
|
|||
|
ef4e4c43ae
|
|||
|
7a3a20cb32
|
|||
|
a842db2d97
|
|||
|
b577359381
|
|||
|
d767676813
|
|||
|
c43bff5a92
|
|||
|
d1b9da0b04
|
|||
|
265e646d96
|
|||
|
f041499eb7
|
|||
|
5207a9778b
|
|||
|
3d509bee8b
|
@@ -1,5 +0,0 @@
|
|||||||
JELLYFIN_ADDRESS=
|
|
||||||
JELLYFIN_USER=
|
|
||||||
JELLYFIN_PASSWORD=
|
|
||||||
|
|
||||||
DISCORD_APP_ID=
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Jellydisc
|
# Jellydisc
|
||||||
Jellydisc updates Discord Rich Presence by checking what you're currently listening to on Jellyfin!
|
Jellydisc updates Discord Rich Presence by checking what you're currently listening to (or watching) on Jellyfin!
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- A Jellyfin server (self-hosted or hosted)
|
- A Jellyfin server (self-hosted or hosted)
|
||||||
@@ -12,7 +12,7 @@ Jellydisc updates Discord Rich Presence by checking what you're currently listen
|
|||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
3. Configure the `.env`, using the provided `.env.example` as a template.
|
3. Configure the `.env`, using the provided `settings.py` as a template.
|
||||||
4. Run the application:
|
4. Run the application:
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
python main.py
|
||||||
|
|||||||
6
changelog/0.0.1.md
Normal file
6
changelog/0.0.1.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 0.0.1
|
||||||
|
|
||||||
|
- Implemented Jellyfin connection
|
||||||
|
- Implemented Discord Rich Presence connection
|
||||||
|
- Set up Rich Presence updating loop to reflect music playback status
|
||||||
|
- Added patch for MacOS compatibility with `pypresence` library
|
||||||
6
changelog/0.1.0.md
Normal file
6
changelog/0.1.0.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 0.1.0
|
||||||
|
|
||||||
|
- Complete rewrite of the codebase, separating concerns into distinct modules for better maintainability
|
||||||
|
- Added support for multiple media types (music, movies, TV shows) in Discord Rich Presence
|
||||||
|
- Added support for `start` and `end` timestamps in Rich Presence
|
||||||
|
- Improved error handling and logging for easier debugging
|
||||||
11
changelog/0.1.1.md
Normal file
11
changelog/0.1.1.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 0.1.1
|
||||||
|
|
||||||
|
- Updated Jellyfin item fetching logic to skip items that are not `Audio`, `Episode`, or `Movie` types, preventing errors when unsupported media types are encountered
|
||||||
|
- 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 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
5
changelog/0.1.2.md
Normal 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
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from pypresence import Presence
|
|
||||||
import os
|
|
||||||
|
|
||||||
def get_rpc():
|
|
||||||
app_id = os.getenv('DISCORD_APP_ID')
|
|
||||||
RPC = Presence(app_id)
|
|
||||||
RPC.connect()
|
|
||||||
return RPC
|
|
||||||
|
|
||||||
14
discord/models.py
Normal file
14
discord/models.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from pydantic import BaseModel, NonNegativeInt
|
||||||
|
from pypresence.types import ActivityType
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordRPCUpdatePayload(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
subtitle: str
|
||||||
|
image_url: str
|
||||||
|
details: str
|
||||||
|
start: Optional[NonNegativeInt]
|
||||||
|
end: Optional[NonNegativeInt]
|
||||||
|
activity_type: ActivityType
|
||||||
56
discord/rpc.py
Normal file
56
discord/rpc.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from pypresence import Presence
|
||||||
|
from settings import settings
|
||||||
|
from discord.models import DiscordRPCUpdatePayload
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class 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.rpc = Presence(settings.discord_app_id)
|
||||||
|
self.rpc.connect()
|
||||||
|
|
||||||
|
self.logger.info("Connected to Discord RPC.")
|
||||||
|
|
||||||
|
def update(self, payload: DiscordRPCUpdatePayload):
|
||||||
|
"""
|
||||||
|
Updates the Discord RPC presence with the provided payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload (DiscordRPCUpdatePayload): The payload containing presence information.
|
||||||
|
"""
|
||||||
|
self.logger.info("Updating Discord RPC presence...")
|
||||||
|
|
||||||
|
self.rpc.update(
|
||||||
|
activity_type=payload.activity_type,
|
||||||
|
details=payload.title,
|
||||||
|
state=payload.subtitle,
|
||||||
|
large_image=payload.image_url,
|
||||||
|
large_text=payload.details,
|
||||||
|
start=payload.start,
|
||||||
|
end=payload.end
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.info("Discord RPC presence updated.")
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""
|
||||||
|
Clears the Discord RPC presence.
|
||||||
|
"""
|
||||||
|
self.logger.info("Clearing Discord RPC presence...")
|
||||||
|
|
||||||
|
self.rpc.clear()
|
||||||
|
|
||||||
|
self.logger.info("Discord RPC presence cleared.")
|
||||||
45
jellyfin.py
45
jellyfin.py
@@ -1,45 +0,0 @@
|
|||||||
from jellyfin_apiclient_python import JellyfinClient
|
|
||||||
from getmac import get_mac_address
|
|
||||||
import os
|
|
||||||
|
|
||||||
def get_client():
|
|
||||||
server_address = os.getenv('JELLYFIN_ADDRESS')
|
|
||||||
username = os.getenv('JELLYFIN_USER')
|
|
||||||
password = os.getenv('JELLYFIN_PASSWORD')
|
|
||||||
host_mac = get_mac_address(hostname="localhost")
|
|
||||||
|
|
||||||
client = JellyfinClient()
|
|
||||||
client.config.app('jellydisc', '0.0.1', os.uname().nodename, host_mac)
|
|
||||||
client.config.data['auth.ssl'] = True
|
|
||||||
|
|
||||||
client.auth.connect_to_address(server_address)
|
|
||||||
client.auth.login(server_address, username, password)
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
def get_active_media(client):
|
|
||||||
server_address = os.getenv('JELLYFIN_ADDRESS')
|
|
||||||
sessions = client.jellyfin.get_sessions()
|
|
||||||
|
|
||||||
for session in sessions:
|
|
||||||
# Check if there is a NowPlayingItem
|
|
||||||
media = session.get('NowPlayingItem')
|
|
||||||
if not media:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip non-audio media
|
|
||||||
media_type = media.get('Type')
|
|
||||||
if media_type != 'Audio':
|
|
||||||
continue
|
|
||||||
|
|
||||||
media_id = media.get('Id')
|
|
||||||
image = f"{server_address}/Items/{media_id}/Images/Primary?maxWidth=300&maxHeight=300"
|
|
||||||
|
|
||||||
return {
|
|
||||||
'id': media_id,
|
|
||||||
'artist': media.get('AlbumArtist', 'Unknown Artist'),
|
|
||||||
'title': media.get('Name', 'Unknown Title'),
|
|
||||||
'image': image,
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
|
||||||
100
jellyfin/api_client.py
Normal file
100
jellyfin/api_client.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from settings import settings
|
||||||
|
from jellyfin_apiclient_python import JellyfinClient
|
||||||
|
from jellyfin.models import (
|
||||||
|
JellyfinMediaItem,
|
||||||
|
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 time
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Initializes the Jellyfin API client and authenticates with the server.
|
||||||
|
"""
|
||||||
|
self.logger.info("Connecting to Jellyfin server...")
|
||||||
|
|
||||||
|
self.client = JellyfinClient()
|
||||||
|
configure_client(self.client)
|
||||||
|
|
||||||
|
self.authenticate()
|
||||||
|
self.logger.info("Connected to Jellyfin server.")
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
"""
|
||||||
|
Authenticates with the Jellyfin server.
|
||||||
|
"""
|
||||||
|
self.logger.info("Authenticating with Jellyfin server...")
|
||||||
|
self.client.auth.connect_to_address(settings.jellyfin_server_url)
|
||||||
|
self.client.auth.login(
|
||||||
|
settings.jellyfin_server_url,
|
||||||
|
settings.jellyfin_username,
|
||||||
|
settings.jellyfin_password
|
||||||
|
)
|
||||||
|
self.last_auth_time = time.time()
|
||||||
|
self.logger.info("Authenticated with Jellyfin server.")
|
||||||
|
|
||||||
|
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:
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
|
self.logger.info("Fetching current playback information...")
|
||||||
|
sessions = self.client.jellyfin.get_sessions()
|
||||||
|
|
||||||
|
if not sessions:
|
||||||
|
self.logger.info("No active playback found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for session in sessions:
|
||||||
|
session_id = session.get('Id')
|
||||||
|
current_item = self.client.jellyfin.get_now_playing(session_id)
|
||||||
|
|
||||||
|
if current_item and current_item.get(
|
||||||
|
'Type') in ['Audio', 'Episode', 'Movie']:
|
||||||
|
self.logger.info("Current playback information fetched.")
|
||||||
|
return self.to_model(current_item)
|
||||||
|
|
||||||
|
self.logger.info("No active playback found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
if media_type == 'Audio':
|
||||||
|
return audio.to_media_item(item)
|
||||||
|
elif media_type == 'Episode':
|
||||||
|
return episode.to_media_item(item, self.client)
|
||||||
|
elif media_type == 'Movie':
|
||||||
|
return movie.to_media_item(item)
|
||||||
78
jellyfin/models.py
Normal file
78
jellyfin/models.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from pydantic import BaseModel, HttpUrl, NonNegativeInt
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional, Union
|
||||||
|
from discord.models import DiscordRPCUpdatePayload
|
||||||
|
from pypresence.types import ActivityType
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinMediaType(StrEnum):
|
||||||
|
AUDIO = 'Audio'
|
||||||
|
MOVIE = 'Movie'
|
||||||
|
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):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: JellyfinMediaType
|
||||||
|
image_url: HttpUrl
|
||||||
|
start: Optional[NonNegativeInt]
|
||||||
|
end: Optional[NonNegativeInt]
|
||||||
|
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
|
||||||
|
)
|
||||||
41
jellyfin/utils/config.py
Normal file
41
jellyfin/utils/config.py
Normal 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
14
jellyfin/utils/image.py
Normal 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"
|
||||||
53
jellyfin/utils/models/audio.py
Normal file
53
jellyfin/utils/models/audio.py
Normal 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
|
||||||
|
)
|
||||||
52
jellyfin/utils/models/episode.py
Normal file
52
jellyfin/utils/models/episode.py
Normal 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
|
||||||
|
)
|
||||||
48
jellyfin/utils/models/movie.py
Normal file
48
jellyfin/utils/models/movie.py
Normal 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
|
||||||
|
)
|
||||||
32
jellyfin/utils/playback.py
Normal file
32
jellyfin/utils/playback.py
Normal 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)
|
||||||
62
main.py
62
main.py
@@ -1,46 +1,34 @@
|
|||||||
from dotenv import load_dotenv
|
from discord.rpc import DiscordRPC
|
||||||
from jellyfin import get_client, get_active_media
|
from jellyfin.api_client import JellyfinApiClient
|
||||||
from discord import get_rpc
|
from settings import settings
|
||||||
from pypresence.types import ActivityType
|
import coloredlogs
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
rpc = get_rpc()
|
def main():
|
||||||
client = get_client()
|
coloredlogs.install(
|
||||||
|
level=logging.INFO,
|
||||||
|
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
def generate_media_id(media):
|
discord_rpc = DiscordRPC()
|
||||||
return f"{media['artist']}-{media['title']}"
|
jellyfin_api_client = JellyfinApiClient()
|
||||||
|
|
||||||
def main_loop(last_media_id):
|
while True:
|
||||||
media = get_active_media(client)
|
try:
|
||||||
|
media_item = jellyfin_api_client.get_current_media()
|
||||||
|
if not media_item:
|
||||||
|
discord_rpc.clear()
|
||||||
|
time.sleep(settings.poll_interval)
|
||||||
|
continue
|
||||||
|
|
||||||
if media is None:
|
discord_rpc.update(media_item.to_rpc_payload())
|
||||||
print("No active media found.")
|
time.sleep(settings.poll_interval)
|
||||||
rpc.clear()
|
except KeyboardInterrupt:
|
||||||
return None
|
logging.info("Shutting down...")
|
||||||
|
discord_rpc.clear()
|
||||||
media_id = generate_media_id(media)
|
break
|
||||||
|
|
||||||
if media_id != last_media_id:
|
|
||||||
print(f"Updating Discord RPC: Listening to {media['title']} by {media['artist']}")
|
|
||||||
|
|
||||||
rpc.update(
|
|
||||||
activity_type=ActivityType.LISTENING,
|
|
||||||
state=f"by {media['artist']}",
|
|
||||||
details=f"Listening to {media['title']}",
|
|
||||||
large_image=media['image'],
|
|
||||||
large_text=media['title'],
|
|
||||||
)
|
|
||||||
return media_id
|
|
||||||
else:
|
|
||||||
print("No change in media. Skipping update.")
|
|
||||||
return last_media_id
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
last_media_id = None
|
main()
|
||||||
print("Starting Jellyfin Discord Rich Presence...")
|
|
||||||
while True:
|
|
||||||
last_media_id = main_loop(last_media_id)
|
|
||||||
time.sleep(15)
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
jellyfin-apiclient-python==1.11.0
|
jellyfin-apiclient-python==1.11.0
|
||||||
python-dotenv==1.2.1
|
|
||||||
getmac==0.9.5
|
getmac==0.9.5
|
||||||
pypresence==4.6.1
|
pypresence==4.6.1
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic-settings==2.12.0
|
||||||
|
coloredlogs==15.0.1
|
||||||
|
autopep8==2.3.2
|
||||||
|
|||||||
6
scripts/format.sh
Executable file
6
scripts/format.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
which autopep8 &> /dev/null || { echo "autopep8 not found, please install it."; exit 1; }
|
||||||
|
|
||||||
|
autopep8 --in-place --aggressive --aggressive --recursive --exclude bin,lib,include,venv,.git,__pycache__ .
|
||||||
|
|
||||||
|
echo "Code formatted with autopep8."
|
||||||
26
settings/__init__.py
Normal file
26
settings/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
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_username: str = Field(..., env="JELLYFIN_USERNAME")
|
||||||
|
jellyfin_password: str = Field(..., env="JELLYFIN_PASSWORD")
|
||||||
|
jellyfin_auth_timeout: int = Field(
|
||||||
|
10 * 60, env="JELLYFIN_AUTH_TIMEOUT") # default 10 minutes
|
||||||
|
|
||||||
|
discord_app_id: str = Field(..., env="DISCORD_APP_ID")
|
||||||
|
|
||||||
|
poll_interval: int = Field(15, env="POLL_INTERVAL") # default 15 seconds
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
Reference in New Issue
Block a user