Skip to content

Smart grouping for marathon style channels - #1678

Open
stasadance wants to merge 2 commits into
chrisbenincasa:devfrom
stasadance:feat/smart-grouping
Open

Smart grouping for marathon style channels#1678
stasadance wants to merge 2 commits into
chrisbenincasa:devfrom
stasadance:feat/smart-grouping

Conversation

@stasadance

Copy link
Copy Markdown

I wanted to make a channel where I had "marathon" style playback so different movie series would get grouped together in chronological release order. The only way I saw this working was with the tags attribute on each movie, in my case with plex they include enough information to link collections together as each movie series has the same collection tag.

I'm not too familiar with this repo yet but was able to add this to the slots editor functionality with help of claude. Please excuse any mistakes, will work on any suggestions and improvements as necessary. Would love for help in testing and making this feature better, I have pushed a tagged build on my fork ghcr.io/stasadance/tunarr:smart-grouping

image

Summary

  • Adds a Group by Tag toggle to movie and smart collection slots, enabling marathon-style playback where
    programs sharing a tag (e.g., "Harry Potter Collection") play back-to-back
  • Groups are ordered by the slot's existing order setting (shuffle, alphabetical, chronological, etc.), while
    programs within each group always play in chronological/release order
  • Configurable options for untagged programs (include as individuals or exclude) and multi-tag behavior (first
    match or all matching groups, deduplicated)

Changes

  • Types: New SlotGroupBySchema with type, ungrouped, and multiTagBehavior fields; added optional
    groupBy to movie and smart-collection slot schemas
  • Server DB: Added tag relation loading to getChannelAndPrograms and getProgramGroupingDescendants so tags
    are available during scheduling
  • Server Scheduling: New groupProgramsByTag() pre-processing step that buckets programs by tag, sorts within
    groups chronologically, orders groups by slot order, and deduplicates across groups
  • Web UI: New SlotGroupByFormControl component with toggle switch and dropdown options, added to movie slot
    and smart collection slot editors

stasadance and others added 2 commits February 26, 2026 16:25
Enable marathon-style playback by grouping programs by their tags
(e.g., "Harry Potter Collection"). Groups are ordered by the slot's
existing order setting, while programs within each group always play
in chronological order. Configurable options for untagged programs
(include/exclude) and multi-tag behavior (first match/all groups).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Programs with multiple tags were appearing multiple times in the
lineup when multiTagBehavior was set to 'all'. Now tracks seen
UUIDs during the flatten step so each program only appears once,
in whichever group comes first in the ordering.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

@chrisbenincasa chrisbenincasa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey! Thanks for the PR. I generally like the idea, and it is something that has been asked for in different ways by users. I do think that there are some gaps in behavior and higher - level questions I have, in addition to some improvements in the code itself to match the style of Tunarr and reuse existing functions, etc for consistent behavior. I've left a first round of comments.

}

// Sort programs within each group chronologically by year
const sortChronologically = (

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These orderers all already exist in ProgramIterator.ts (and are more battle tested / correct)

* When order is 'next' or 'chronological', groups are sorted by earliest year.
* When order is 'ordered_shuffle', groups are sorted then rotated randomly.
*/
export function groupProgramsByTag(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used outside of this file? can we remove the export?

const ungrouped: SlotSchedulerProgram[] = [];

for (const program of programs) {
const tags = program.tags?.map((t) => t.tag.tag).filter(Boolean) ?? [];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tunarr has a convenience method for this operation: seq.collect (seq is imported from !tunarr/shared/util

<>
<FormHelperText>
Group programs by shared tags for marathon-style playback. Programs
within each group play in chronological order.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true? The code above to create the grouped chunks takes in the slot's configured order and outputs the groupBy program list using that orderer

});

export const SlotGroupBySchema = z.object({
type: z.literal('tag'),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only really necessary if we are using a discriminated union and there are other types of "group by"s we need to think about. If you think there will be more in the future, we could preemptively make this a discriminated union -- otherwise I think we can omit this field entirely.

export function groupProgramsByTag(
programs: SlotSchedulerProgram[],
groupBy: SlotGroupBy,
order: string,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we don't have a named type here, but let's prefer BaseSlot['order'] or something similar for tighter type checking.

const tags = program.tags?.map((t) => t.tag.tag).filter(Boolean) ?? [];

if (tags.length === 0) {
if (groupBy.ungrouped === 'include') {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] can be consolidated to a single conditional claude tags.length === 0 && groupBy.ungrouped === 'include'

}

// Flatten groups into final program list, deduplicating programs
// that appear in multiple groups (when multiTagBehavior === 'all')

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little confused by this - if we use multiTagBehavior 'all' and a program appears under multiple tags, it'll only play in the marathon for the first tag? I understand removing repeats, but the behavior still strikes me as a little surprising. Additionally, I assume the tags don't have a consistent ordering?

@chrisbenincasa

Copy link
Copy Markdown
Owner

Friendly ping :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants