Compare commits

..

45 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
ede88e55a9 fix episode parent_id retrieval 2025-12-10 02:54:14 +01:00
59183e7021 add autopep8 formatting 2025-12-10 02:51:46 +01:00
9242b02957 add colored logs 2025-12-10 02:50:17 +01:00
c3b5d7633a Merge pull request '0.1.1' (#1) from 0.1.1 into main
Reviewed-on: #1
2025-12-10 02:44:51 +01:00
210aa0b85e fetch image by parent id 2025-12-10 02:43:49 +01:00
fd2ff8ac6b fix current item type check 2025-12-10 02:38:58 +01:00
0183d42d55 get music image from parent id 2025-12-10 02:37:25 +01:00
d12f2e5a79 added poll interval env 2025-12-10 02:24:29 +01:00
a40d307956 increment version 2025-12-10 02:22:13 +01:00
918b3bae14 show album with year if possible 2025-12-10 02:17:08 +01:00
ce9223a71a calculate start and end via play state 2025-12-10 02:14:34 +01:00
0a31d364d9 update last media 2025-12-10 02:08:46 +01:00
646b1efd8c update readme 2025-12-10 01:49:27 +01:00
8f29ed7aee move settings to separate folder 2025-12-10 01:48:54 +01:00
dc94bb8ba5 move auth timeout to settings 2025-12-10 01:48:15 +01:00
ef4e4c43ae re-auth every 10min 2025-12-10 01:46:18 +01:00
7a3a20cb32 calculate start and end 2025-12-10 01:42:24 +01:00
a842db2d97 add keyboard interrupt handling 2025-12-10 01:16:12 +01:00
b577359381 clean up episode metadata 2025-12-10 01:13:42 +01:00
d767676813 extract movie date 2025-12-10 01:12:31 +01:00
c43bff5a92 change readme 2025-12-09 23:59:29 +01:00
d1b9da0b04 update rpc from jellyfin 2025-12-09 23:58:16 +01:00
265e646d96 add jellyfin api client 2025-12-09 23:48:05 +01:00
f041499eb7 add discord rpc class 2025-12-09 23:30:01 +01:00
5207a9778b add pydantic settings 2025-12-09 23:23:45 +01:00
3d509bee8b add pydantic 2025-12-09 23:20:22 +01:00
22 changed files with 579 additions and 99 deletions

View File

@@ -1,5 +0,0 @@
JELLYFIN_ADDRESS=
JELLYFIN_USER=
JELLYFIN_PASSWORD=
DISCORD_APP_ID=

View File

@@ -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
View 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
View 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
View 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
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,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
View 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
View 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.")

View File

@@ -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
View 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
View 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
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)

62
main.py
View File

@@ -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)

View File

@@ -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
View 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
View 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()