Compare commits

...

51 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
9d08dda1c3 fix main loop return value 2025-12-09 21:12:58 +01:00
5052c40f68 get first media 2025-12-09 21:12:33 +01:00
be33090bd2 clear presence when media isnt active 2025-12-09 20:51:58 +01:00
56419497b9 add mac notice 2025-12-09 20:43:52 +01:00
afd4f1b26e add readme 2025-12-09 20:42:37 +01:00
b776960ead add env example 2025-12-09 20:41:24 +01:00
21 changed files with 601 additions and 86 deletions

24
README.md Normal file
View File

@@ -0,0 +1,24 @@
# Jellydisc
Jellydisc updates Discord Rich Presence by checking what you're currently listening to (or watching) on Jellyfin!
## Requirements
- A Jellyfin server (self-hosted or hosted)
- A Discord account
- Python 3.9 or higher
## Installation
1. Clone this repository
2. Install the required dependencies:
```bash
pip install -r requirements.txt
```
3. Configure the `.env`, using the provided `settings.py` as a template.
4. Run the application:
```bash
python main.py
```
## NOTE
On MacOS, you'll need to patch the `pypresence` library to work around an issue with it being unable to find the Discord IPC socket. You can do this by editing the `pypresence/utils.py` file.
The changes are kept in the `patches/pypresence_macfix.patch` file.

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,41 +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()
active_media = []
for session in sessions:
media = session.get('NowPlayingItem')
if not media:
continue
media_id = media.get('Id')
image = f"{server_address}/Items/{media_id}/Images/Primary?maxWidth=300&maxHeight=300"
media_info = {
'artist': media.get('AlbumArtist', 'Unknown Artist'),
'title': media.get('Name', 'Unknown Title'),
'image': image,
}
active_media.append(media_info)
return active_media

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)

60
main.py
View File

@@ -1,44 +1,34 @@
from dotenv import load_dotenv
from jellyfin import get_client, get_active_media
from discord import get_rpc
from discord.rpc import DiscordRPC
from jellyfin.api_client import JellyfinApiClient
from settings import settings
import coloredlogs
import logging
import time
load_dotenv()
rpc = get_rpc()
client = get_client()
def main():
coloredlogs.install(
level=logging.INFO,
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
def generate_media_id(media):
return f"{media['artist']}-{media['title']}"
discord_rpc = DiscordRPC()
jellyfin_api_client = JellyfinApiClient()
def main_loop(last_media_id):
media_list = get_active_media(client)
while True:
try:
media_item = jellyfin_api_client.get_current_media()
if not media_item:
discord_rpc.clear()
time.sleep(settings.poll_interval)
continue
if len(media_list) == 0:
print("No active media found.")
return
media = media_list[0]
media_id = generate_media_id(media)
if media_id != last_media_id:
print(f"Updating Discord RPC: Listening to {media['title']} by {media['artist']}")
rpc.update(
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
discord_rpc.update(media_item.to_rpc_payload())
time.sleep(settings.poll_interval)
except KeyboardInterrupt:
logging.info("Shutting down...")
discord_rpc.clear()
break
if __name__ == "__main__":
last_media_id = None
print("Starting Jellyfin Discord Rich Presence...")
while True:
last_media_id = main_loop(last_media_id)
time.sleep(15)
main()

View File

@@ -1,4 +1,7 @@
jellyfin-apiclient-python==1.11.0
python-dotenv==1.2.1
getmac==0.9.5
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()