Auto-tune channels can now carry per-content-source rotation weights (weighted
round-robin, e.g. 3x Show A / 1x Show B) and query corrections (exclude /
add-untagged), supplied at bulk-create time via an optional
`sources: [{sourceId, weight, excluded}]` on each AutoTunedChannelRequest.
Design (Option A, reuse #70): when a source is customized the channel is backed
by a system-owned MultiCollection of per-source SmartCollections carrying the
weights, with PlaybackOrder.WeightedShuffle -- the exact path
WeightedShuffleCollectionEnumerator already consumes. All-default weights keep
the #69 single-SmartCollection fair-share shape.
- Discriminators: TV -> live show_title:"X" (episodes carry no parent-show id in
the index); movies -> stable id:{mediaItemId}.
- Materialization is axis-dependent: TV materializes every base show individually
(un-weighted shows keep per-show fair-share) + a live remainder at weight 1;
MovieGenre materializes only touched movies + one count-weighted remainder.
- Remainder = (base) AND NOT (materialized union excluded) -- a partition.
- New nullable OwnedByChannelId on SmartCollection + MultiCollection
(dual-provider migration); owned rows are hidden from the collection lists and
cascade-cleaned on channel delete.
Tests: AutoTuneAxisMap query/partition units; DB-backed weighted-path handler
tests (TV materialize-all, movie count-remainder, exclusion, no-customization
fallback); delete-cleanup. Docs: decisions.md, domain-model.md, api-conventions.md;
OpenAPI trio regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
4.9 KiB
C#
97 lines
4.9 KiB
C#
using ErsatzTV.Core.Domain;
|
|
|
|
namespace ErsatzTV.Application.Channels;
|
|
|
|
public static class AutoTuneAxisMap
|
|
{
|
|
// Server-owned Lucene smart-collection query for an axis value.
|
|
public static string GenerateQuery(AutoTuneAxis axis, string value)
|
|
{
|
|
string escaped = EscapeLuceneValue(value);
|
|
return axis switch
|
|
{
|
|
AutoTuneAxis.TvShow => $"type:episode AND show_title:\"{escaped}\"",
|
|
AutoTuneAxis.TvGenre => $"type:episode AND genre:\"{escaped}\"",
|
|
AutoTuneAxis.MovieGenre => $"type:movie AND genre:\"{escaped}\"",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
|
};
|
|
}
|
|
|
|
// Human-facing channel name. Movie-genre channels are suffixed so a genre that exists for
|
|
// both TV and movies ("Comedy" vs "Comedy Movies") does not produce two identically-named channels.
|
|
public static string GenerateName(AutoTuneAxis axis, string value) =>
|
|
axis switch
|
|
{
|
|
AutoTuneAxis.TvShow => value,
|
|
AutoTuneAxis.TvGenre => value,
|
|
AutoTuneAxis.MovieGenre => $"{value} Movies",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
|
};
|
|
|
|
// PseudoTV per-type defaults: single-show channels play in episode order; genre channels shuffle.
|
|
public static PlaybackOrder PlaybackOrderFor(AutoTuneAxis axis) =>
|
|
axis switch
|
|
{
|
|
AutoTuneAxis.TvShow => PlaybackOrder.SeasonEpisode,
|
|
AutoTuneAxis.TvGenre => PlaybackOrder.Shuffle,
|
|
AutoTuneAxis.MovieGenre => PlaybackOrder.Shuffle,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
|
};
|
|
|
|
// Per-source member query for a weighted auto-tune channel (#425). The discriminator identifies ONE
|
|
// content source within the channel's axis:
|
|
// * TV axes -> the show title. Episodes carry no parent-show id in the search index (only show_title
|
|
// is denormalized onto them), so show_title is the only field that selects a show's episodes. It is
|
|
// the same discriminator the TvShow axis already uses, so this introduces no new fragility class;
|
|
// a post-create show rename empties the member (items fall through to the remainder) until re-tuned.
|
|
// * MovieGenre -> the movie's media-item id (the stable, rename-proof `id` field; a movie IS the
|
|
// played item, so its own id selects it exactly).
|
|
// Deliberately discriminator-ONLY (no genre clause): membership is decided when the channel is tuned,
|
|
// so a materialized show airs all its episodes and the remainder subtracts the whole source (below).
|
|
public static string GenerateSourceQuery(AutoTuneAxis axis, string discriminator) =>
|
|
axis switch
|
|
{
|
|
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
|
$"type:episode AND show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
|
AutoTuneAxis.MovieGenre => $"type:movie AND id:{discriminator}",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
|
};
|
|
|
|
// The bare clause used to subtract a materialized/excluded source from the remainder query (below).
|
|
// Mirrors GenerateSourceQuery's discriminator field, minus the type prefix.
|
|
public static string SourceDiscriminatorClause(AutoTuneAxis axis, string discriminator) =>
|
|
axis switch
|
|
{
|
|
AutoTuneAxis.TvShow or AutoTuneAxis.TvGenre =>
|
|
$"show_title:\"{EscapeLuceneValue(discriminator)}\"",
|
|
AutoTuneAxis.MovieGenre => $"id:{discriminator}",
|
|
_ => throw new ArgumentOutOfRangeException(nameof(axis), axis, null)
|
|
};
|
|
|
|
// The catch-all remainder query: the base axis query minus every materialized/excluded source, so the
|
|
// base set is partitioned across (member sources + remainder) with no item counted twice and none
|
|
// dropped. Returns the plain base query when there is nothing to subtract. Emitted as valid classic
|
|
// Lucene — `(base) AND NOT (d1 OR d2 ...)` — because a ParseException silently escapes the whole query
|
|
// into a literal (SearchQueryParser.ParseQuery fallback).
|
|
public static string GenerateRemainderQuery(
|
|
AutoTuneAxis axis,
|
|
string value,
|
|
IReadOnlyCollection<string> subtractedDiscriminators)
|
|
{
|
|
string baseQuery = GenerateQuery(axis, value);
|
|
if (subtractedDiscriminators is null || subtractedDiscriminators.Count == 0)
|
|
{
|
|
return baseQuery;
|
|
}
|
|
|
|
string negated = string.Join(
|
|
" OR ",
|
|
subtractedDiscriminators.Select(d => SourceDiscriminatorClause(axis, d)));
|
|
return $"({baseQuery}) AND NOT ({negated})";
|
|
}
|
|
|
|
// Escape a value for a Lucene double-quoted phrase: backslash first, then double-quote.
|
|
public static string EscapeLuceneValue(string value) =>
|
|
(value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
|
}
|