Jellyfin/Emby: Sync across the view times

Signed-off-by: Luis Garcia <git@luigi311.com>
This commit is contained in:
Luis Garcia
2025-07-11 23:23:46 +00:00
parent 24f56769f9
commit 75f7f576ac
4 changed files with 105 additions and 52 deletions

View File

@@ -1,12 +1,13 @@
# Functions for Jellyfin and Emby
from datetime import datetime
import requests
import traceback
import os
from math import floor
from typing import Any, Literal
from packaging.version import parse, Version
from loguru import logger
from urllib.parse import quote
from src.functions import (
search_mapping,
@@ -76,15 +77,21 @@ def get_mediaitem(
generate_guids: bool,
generate_locations: bool,
) -> MediaItem:
user_data = item.get("UserData", {})
last_played_date = user_data.get("LastPlayedDate")
viewed_date = datetime.today()
if last_played_date:
viewed_date = datetime.fromisoformat(last_played_date.replace("Z", "+00:00"))
return MediaItem(
identifiers=extract_identifiers_from_item(
server_type, item, generate_guids, generate_locations
),
status=WatchedStatus(
completed=item.get("UserData", {}).get("Played"),
time=floor(
item.get("UserData", {}).get("PlaybackPositionTicks", 0) / 10000
),
completed=user_data.get("Played"),
time=floor(user_data.get("PlaybackPositionTicks", 0) / 10000),
viewed_date=viewed_date,
),
)
@@ -311,7 +318,7 @@ class JellyfinEmby:
movie_items = []
watched_items = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
+ f"?ParentId={library_id}&Filters=IsPlayed&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources,UserDataLastPlayedDate",
"get",
)
@@ -320,7 +327,7 @@ class JellyfinEmby:
in_progress_items = self.query(
f"/Users/{user_id}/Items"
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources",
+ f"?ParentId={library_id}&Filters=IsResumable&IncludeItemTypes=Movie&Recursive=True&Fields=ItemCounts,ProviderIds,MediaSources,UserDataLastPlayedDate",
"get",
)
@@ -388,7 +395,7 @@ class JellyfinEmby:
show_episodes = self.query(
f"/Shows/{show.get('Id')}/Episodes"
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources",
+ f"?userId={user_id}&isPlaceHolder=false&Fields=ProviderIds,MediaSources,UserDataLastPlayedDate",
"get",
)
@@ -558,11 +565,18 @@ class JellyfinEmby:
jelly_identifiers, stored_movie.identifiers
):
jellyfin_video_id = jellyfin_video.get("Id")
viewed_date: str = (
stored_movie.status.viewed_date.isoformat(
timespec="milliseconds"
).replace("+00:00", "Z")
)
if stored_movie.status.completed:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as watched for {user_name} in {library_name}"
if not dryrun:
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}",
f"/Users/{user_id}/PlayedItems/{jellyfin_video_id}?DatePlayed={quote(viewed_date)}",
"post",
)
@@ -581,14 +595,15 @@ class JellyfinEmby:
msg = f"{self.server_type}: {jellyfin_video.get('Name')} as partially watched for {floor(stored_movie.status.time / 60_000)} minutes for {user_name} in {library_name}"
if not dryrun:
playback_position_payload: dict[str, float] = {
user_data_payload: dict[str, float] = {
"PlaybackPositionTicks": stored_movie.status.time
* 10_000,
"LastPlayedDate": viewed_date,
}
self.query(
f"/Users/{user_id}/Items/{jellyfin_video_id}/UserData",
"post",
json=playback_position_payload,
json=user_data_payload,
)
logger.success(f"{'[DRYRUN] ' if dryrun else ''}{msg}")
@@ -671,6 +686,13 @@ class JellyfinEmby:
stored_ep.identifiers,
):
jellyfin_episode_id = jellyfin_episode.get("Id")
viewed_date: str = (
stored_ep.status.viewed_date.isoformat(
timespec="milliseconds"
).replace("+00:00", "Z")
)
if stored_ep.status.completed:
msg = (
f"{self.server_type}: {jellyfin_episode.get('SeriesName')} {jellyfin_episode.get('SeasonName')} Episode {jellyfin_episode.get('IndexNumber')} {jellyfin_episode.get('Name')}"
@@ -678,7 +700,7 @@ class JellyfinEmby:
)
if not dryrun:
self.query(
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}",
f"/Users/{user_id}/PlayedItems/{jellyfin_episode_id}?DatePlayed={quote(viewed_date)}",
"post",
)
@@ -703,14 +725,15 @@ class JellyfinEmby:
)
if not dryrun:
playback_position_payload = {
user_data_payload = {
"PlaybackPositionTicks": stored_ep.status.time
* 10_000,
"LastPlayedDate": viewed_date,
}
self.query(
f"/Users/{user_id}/Items/{jellyfin_episode_id}/UserData",
"post",
json=playback_position_payload,
json=user_data_payload,
)
logger.success(

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timezone
import requests
from loguru import logger
@@ -83,11 +84,19 @@ def get_mediaitem(
generate_guids: bool = True,
generate_locations: bool = True,
) -> MediaItem:
last_viewed_at = item.lastViewedAt
viewed_date = datetime.today()
if last_viewed_at:
viewed_date = last_viewed_at.replace(tzinfo=timezone.utc)
return MediaItem(
identifiers=extract_identifiers_from_item(
item, generate_guids, generate_locations
),
status=WatchedStatus(completed=completed, time=item.viewOffset),
status=WatchedStatus(
completed=completed, time=item.viewOffset, viewed_date=viewed_date
),
)

View File

@@ -1,4 +1,5 @@
import copy
from datetime import datetime
from pydantic import BaseModel, Field
from loguru import logger
from typing import Any
@@ -21,6 +22,7 @@ class MediaIdentifiers(BaseModel):
class WatchedStatus(BaseModel):
completed: bool
time: int
viewed_date: datetime
class MediaItem(BaseModel):