Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""add season/series aggregate mediainfo columns

Revision ID: 9a0b7a52a6f1
Revises: c5f60f5a7b29
Create Date: 2026-04-26 13:25:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "9a0b7a52a6f1"
down_revision: Union[str, None] = "c5f60f5a7b29"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("series", schema=None) as batch_op:
batch_op.add_column(sa.Column("has_hdr", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("has_dolby_vision", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("max_video_width", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("max_video_height", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("video_codec_families", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column("audio_codec_families", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column("max_audio_channels", sa.SmallInteger(), nullable=True))
batch_op.add_column(sa.Column("subtitle_languages", sa.JSON(), nullable=True))

with op.batch_alter_table("seasons", schema=None) as batch_op:
batch_op.add_column(sa.Column("has_hdr", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("has_dolby_vision", sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column("max_video_width", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("max_video_height", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("video_codec_families", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column("audio_codec_families", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column("max_audio_channels", sa.SmallInteger(), nullable=True))
batch_op.add_column(sa.Column("subtitle_languages", sa.JSON(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("seasons", schema=None) as batch_op:
batch_op.drop_column("subtitle_languages")
batch_op.drop_column("max_audio_channels")
batch_op.drop_column("audio_codec_families")
batch_op.drop_column("video_codec_families")
batch_op.drop_column("max_video_height")
batch_op.drop_column("max_video_width")
batch_op.drop_column("has_dolby_vision")
batch_op.drop_column("has_hdr")

with op.batch_alter_table("series", schema=None) as batch_op:
batch_op.drop_column("subtitle_languages")
batch_op.drop_column("max_audio_channels")
batch_op.drop_column("audio_codec_families")
batch_op.drop_column("video_codec_families")
batch_op.drop_column("max_video_height")
batch_op.drop_column("max_video_width")
batch_op.drop_column("has_dolby_vision")
batch_op.drop_column("has_hdr")
17 changes: 17 additions & 0 deletions backend/api/routes/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,14 @@ async def get_series(
"tagline": series.tagline,
"last_viewed_at": to_utc_isoformat(series.last_viewed_at),
"view_count": series.view_count,
"has_hdr": series.has_hdr,
"has_dolby_vision": series.has_dolby_vision,
"max_video_width": series.max_video_width,
"max_video_height": series.max_video_height,
"video_codec_families": series.video_codec_families,
"audio_codec_families": series.audio_codec_families,
"max_audio_channels": series.max_audio_channels,
"subtitle_languages": series.subtitle_languages,
"status": status,
"has_season_candidates": series.id in series_with_season_cands
and candidate is None,
Expand Down Expand Up @@ -505,8 +513,17 @@ async def get_series_seasons(
episode_count=season.episode_count,
size=season.size,
view_count=season.view_count or 0,
added_at=to_utc_isoformat(season.added_at),
last_viewed_at=to_utc_isoformat(season.last_viewed_at),
air_date=to_utc_isoformat(season.air_date),
has_hdr=season.has_hdr,
has_dolby_vision=season.has_dolby_vision,
max_video_width=season.max_video_width,
max_video_height=season.max_video_height,
video_codec_families=season.video_codec_families,
audio_codec_families=season.audio_codec_families,
max_audio_channels=season.max_audio_channels,
subtitle_languages=season.subtitle_languages,
status=season_status,
)
)
Expand Down
18 changes: 18 additions & 0 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,15 @@ class Series(Base):
# watch tracking (from plex/jellyfin/emby)
last_viewed_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
view_count: Mapped[int] = mapped_column(Integer, default=0)
# aggregate media signals
has_hdr: Mapped[bool | None] = mapped_column(Boolean, default=None)
has_dolby_vision: Mapped[bool | None] = mapped_column(Boolean, default=None)
max_video_width: Mapped[int | None] = mapped_column(Integer, default=None)
max_video_height: Mapped[int | None] = mapped_column(Integer, default=None)
video_codec_families: Mapped[list[str] | None] = mapped_column(JSON, default=None)
audio_codec_families: Mapped[list[str] | None] = mapped_column(JSON, default=None)
max_audio_channels: Mapped[int | None] = mapped_column(SmallInteger, default=None)
subtitle_languages: Mapped[list[str] | None] = mapped_column(JSON, default=None)

# lifecycle tracking
added_at: Mapped[datetime | None] = mapped_column(
Expand Down Expand Up @@ -489,6 +498,15 @@ class Season(Base):
view_count: Mapped[int | None] = mapped_column(Integer, default=None)
last_viewed_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
air_date: Mapped[datetime | None] = mapped_column(DateTime, default=None)
# aggregate media signals
has_hdr: Mapped[bool | None] = mapped_column(Boolean, default=None)
has_dolby_vision: Mapped[bool | None] = mapped_column(Boolean, default=None)
max_video_width: Mapped[int | None] = mapped_column(Integer, default=None)
max_video_height: Mapped[int | None] = mapped_column(Integer, default=None)
video_codec_families: Mapped[list[str] | None] = mapped_column(JSON, default=None)
audio_codec_families: Mapped[list[str] | None] = mapped_column(JSON, default=None)
max_audio_channels: Mapped[int | None] = mapped_column(SmallInteger, default=None)
subtitle_languages: Mapped[list[str] | None] = mapped_column(JSON, default=None)

# service-specific IDs for direct ops
jellyfin_season_id: Mapped[str | None] = mapped_column(String(100), default=None)
Expand Down
31 changes: 30 additions & 1 deletion backend/models/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class MovieVersionData:
container: str | None = None
# ms (plex is milliseconds, jellyfin/emby is .net ticks so 'milliseconds = RunTimeTicks / 10,000')
duration: float | None = None

# video
video_track_count: int | None = None
# preserve raw value from provider
Expand Down Expand Up @@ -108,9 +108,19 @@ class AggregatedSeasonData:
episode_count: int
view_count: int
last_viewed_at: datetime | None
added_at: datetime | None = None
air_date: datetime | None = None
# plex parentRatingKey or jellyfin/emby season item ID for direct ops
service_season_id: str | None = None
# aggregate media signals
has_hdr: bool | None = None
has_dolby_vision: bool | None = None
max_video_width: int | None = None
max_video_height: int | None = None
video_codec_families: list[str] | None = None
audio_codec_families: list[str] | None = None
max_audio_channels: int | None = None
subtitle_languages: list[str] | None = None


@dataclass(slots=True, frozen=True)
Expand Down Expand Up @@ -302,6 +312,15 @@ class SeriesWithStatus(BaseModel):
# watch tracking
last_viewed_at: str | None
view_count: int
# aggregate media signals
has_hdr: bool | None = None
has_dolby_vision: bool | None = None
max_video_width: int | None = None
max_video_height: int | None = None
video_codec_families: list[str] | None = None
audio_codec_families: list[str] | None = None
max_audio_channels: int | None = None
subtitle_languages: list[str] | None = None

# status
status: MediaStatusInfo
Expand All @@ -320,8 +339,18 @@ class SeasonWithStatus(BaseModel):
episode_count: int | None
size: int | None
view_count: int
added_at: str | None
last_viewed_at: str | None
air_date: str | None
# aggregate media signals
has_hdr: bool | None = None
has_dolby_vision: bool | None = None
max_video_width: int | None = None
max_video_height: int | None = None
video_codec_families: list[str] | None = None
audio_codec_families: list[str] | None = None
max_audio_channels: int | None = None
subtitle_languages: list[str] | None = None
status: MediaStatusInfo


Expand Down
114 changes: 113 additions & 1 deletion backend/services/emby_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,16 @@ async def get_series_sizes_for_library(
season_view_counts: dict[tuple[str, int], int] = {}
season_last_viewed: dict[tuple[str, int], datetime | None] = {}
season_ids: dict[tuple[str, int], str] = {} # Emby/Jellyfin SeasonId
season_air_date: dict[tuple[str, int], datetime | None] = {}
season_added_at: dict[tuple[str, int], datetime | None] = {}
season_has_hdr: dict[tuple[str, int], bool] = {}
season_has_dv: dict[tuple[str, int], bool] = {}
season_max_width: dict[tuple[str, int], int] = {}
season_max_height: dict[tuple[str, int], int] = {}
season_video_families: dict[tuple[str, int], set[str]] = {}
season_audio_families: dict[tuple[str, int], set[str]] = {}
season_max_audio_channels: dict[tuple[str, int], int] = {}
season_subtitle_languages: dict[tuple[str, int], set[str]] = {}

start_index = 0
limit = 500
Expand All @@ -455,7 +465,7 @@ async def get_series_sizes_for_library(
"includeItemTypes": "Episode",
"recursive": "true",
"Fields": (
"MediaSources,SeriesId,DateCreated,ParentIndexNumber,SeasonId,UserData,"
"MediaSources,MediaStreams,SeriesId,DateCreated,PremiereDate,ParentIndexNumber,SeasonId,UserData,"
"UserDataLastPlayedDate,UserDataPlayCount"
),
"ParentId": library_id,
Expand Down Expand Up @@ -500,6 +510,30 @@ async def get_series_sizes_for_library(
season_sizes[sk] = season_sizes.get(sk, 0) + episode_size
season_episode_counts[sk] = season_episode_counts.get(sk, 0) + 1

# season air date = earliest episode premiere date
premiere_date = episode.get("PremiereDate")
if premiere_date:
try:
dt = datetime.fromisoformat(
premiere_date.replace("Z", "+00:00")
)
prev = season_air_date.get(sk)
if prev is None or dt < prev:
season_air_date[sk] = dt
except (TypeError, ValueError):
pass

# season added_at = earliest episode date created
created_date = episode.get("DateCreated")
if created_date:
try:
dt = datetime.fromisoformat(created_date.replace("Z", "+00:00"))
prev = season_added_at.get(sk)
if prev is None or dt < prev:
season_added_at[sk] = dt
except (TypeError, ValueError):
pass

# store Emby/Jellyfin SeasonId for first episode of each season
if sk not in season_ids and episode.get("SeasonId"):
season_ids[sk] = episode["SeasonId"]
Expand All @@ -519,6 +553,71 @@ async def get_series_sizes_for_library(
elif sk not in season_last_viewed:
season_last_viewed[sk] = None

# media aggregate signals per season
for source in episode.get("MediaSources", []):
media_width = as_int(source.get("Width"))
if media_width is not None:
season_max_width[sk] = max(
season_max_width.get(sk, 0), media_width
)
media_height = as_int(source.get("Height"))
if media_height is not None:
season_max_height[sk] = max(
season_max_height.get(sk, 0), media_height
)
streams = source.get("MediaStreams", []) or []
for stream in streams:
stream_type = str(stream.get("Type", "")).lower()
if stream_type == "video":
vcodec = stream.get("Codec")
if vcodec:
vf = normalize_video_codec_family(str(vcodec))
if vf:
season_video_families.setdefault(sk, set()).add(vf)
width = as_int(stream.get("Width"))
if width is not None:
season_max_width[sk] = max(
season_max_width.get(sk, 0), width
)
height = as_int(stream.get("Height"))
if height is not None:
season_max_height[sk] = max(
season_max_height.get(sk, 0), height
)
dv_profile = stream.get("DvProfile")
video_range_type = str(
stream.get("VideoRangeType", "")
).lower()
has_dv = (
dv_profile is not None or "dovi" in video_range_type
)
has_hdr = (
has_dv
or bool(stream.get("IsHdr"))
or bool(video_range_type)
)
if has_dv:
season_has_dv[sk] = True
if has_hdr:
season_has_hdr[sk] = True
elif stream_type == "audio":
acodec = stream.get("Codec")
if acodec:
af = normalize_audio_codec_family(str(acodec))
if af:
season_audio_families.setdefault(sk, set()).add(af)
channels = as_int(stream.get("Channels"))
if channels is not None:
season_max_audio_channels[sk] = max(
season_max_audio_channels.get(sk, 0), channels
)
elif stream_type == "subtitle":
lang = stream.get("Language")
if lang:
season_subtitle_languages.setdefault(sk, set()).add(
str(lang).lower()
)

total_record_count = get_data.get("TotalRecordCount", 0) # pyright: ignore [reportAttributeAccessIssue]
start_index += len(episodes)
if start_index >= total_record_count:
Expand All @@ -537,7 +636,20 @@ async def get_series_sizes_for_library(
episode_count=season_episode_counts.get(sk, 0),
view_count=agg_view,
last_viewed_at=lva,
added_at=season_added_at.get(sk),
air_date=season_air_date.get(sk),
service_season_id=season_ids.get(sk),
has_hdr=True if season_has_hdr.get(sk) else None,
has_dolby_vision=True if season_has_dv.get(sk) else None,
max_video_width=season_max_width.get(sk),
max_video_height=season_max_height.get(sk),
video_codec_families=sorted(season_video_families.get(sk, set()))
or None,
audio_codec_families=sorted(season_audio_families.get(sk, set()))
or None,
max_audio_channels=season_max_audio_channels.get(sk),
subtitle_languages=sorted(season_subtitle_languages.get(sk, set()))
or None,
)

return series_sizes, season_data
Expand Down
Loading
Loading