evenly divide epg time for schedule blocks (#1607)

* add checkbox to include block items in program guide

* evenly divide epg time for schedule blocks
This commit is contained in:
Jason Dove
2024-02-10 20:59:29 -06:00
committed by GitHub
parent 60b479e330
commit d55ba235bf
24 changed files with 20125 additions and 105 deletions
@@ -159,27 +159,81 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
List<PlayoutItem> sorted = [];
foreach (Playout playout in playouts)
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Block:
sorted.AddRange(playouts.Collect(p => p.Items).OrderBy(pi => pi.Start));
break;
case ProgramSchedulePlayoutType.ExternalJson:
sorted.AddRange(await CollectExternalJsonItems(playout.ExternalJsonFile));
break;
}
}
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
foreach (Playout playout in playouts)
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
var floodSorted = playouts.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList();
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.Block:
var blockSorted = playouts.Collect(p => p.Items).OrderBy(pi => pi.Start).ToList();
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.ExternalJson:
List<PlayoutItem> externalJsonSorted = await CollectExternalJsonItems(playout.ExternalJsonFile);
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
}
private async Task WritePlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
@@ -232,83 +286,159 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
: finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
Movie templateMovie => await ProcessMovieTemplate(
request,
templateMovie,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
movieTemplate),
Episode templateEpisode => await ProcessEpisodeTemplate(
request,
templateEpisode,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
episodeTemplate),
MusicVideo templateMusicVideo => await ProcessMusicVideoTemplate(
request,
templateMusicVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
musicVideoTemplate),
Song templateSong => await ProcessSongTemplate(
request,
templateSong,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
songTemplate),
OtherVideo templateOtherVideo => await ProcessOtherVideoTemplate(
request,
templateOtherVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
otherVideoTemplate),
_ => Option<string>.None
};
foreach (string templateOutput in maybeTemplateOutput)
{
MarkupMinificationResult minified = minifier.Minify(templateOutput);
await xml.WriteRawAsync(minified.MinifiedContent);
}
await WriteItemToXml(
request,
displayItem,
start,
stop,
hasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
i++;
}
}
private async Task WriteBlockPlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
await xml.FlushAsync();
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
TimeSpan perItem = groupDuration / itemsToInclude.Count;
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
DateTimeOffset currentFinish = currentStart + perItem;
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
hasCustomTitle: false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
}
}
private async Task WriteItemToXml(
RefreshChannelData request,
PlayoutItem displayItem,
string start,
string stop,
bool hasCustomTitle,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
Movie templateMovie => await ProcessMovieTemplate(
request,
templateMovie,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
movieTemplate),
Episode templateEpisode => await ProcessEpisodeTemplate(
request,
templateEpisode,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
episodeTemplate),
MusicVideo templateMusicVideo => await ProcessMusicVideoTemplate(
request,
templateMusicVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
musicVideoTemplate),
Song templateSong => await ProcessSongTemplate(
request,
templateSong,
start,
stop,
hasCustomTitle,
displayItem,
title,
subtitle,
templateContext,
songTemplate),
OtherVideo templateOtherVideo => await ProcessOtherVideoTemplate(
request,
templateOtherVideo,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
otherVideoTemplate),
_ => Option<string>.None
};
foreach (string templateOutput in maybeTemplateOutput)
{
MarkupMinificationResult minified = minifier.Minify(templateOutput);
await xml.WriteRawAsync(minified.MinifiedContent);
}
}
private static async Task<Option<string>> ProcessMovieTemplate(
@@ -12,4 +12,5 @@ public record BlockItemViewModel(
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide);
@@ -9,4 +9,5 @@ public record ReplaceBlockItem(
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder);
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide);
@@ -53,7 +53,8 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private static Task<Validation<BaseError, Block>> Validate(TvContext dbContext, ReplaceBlockItems request) =>
+2 -1
View File
@@ -30,7 +30,8 @@ internal static class Mapper
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
blockItem.PlaybackOrder);
blockItem.PlaybackOrder,
blockItem.IncludeInProgramGuide);
internal static TemplateGroupViewModel ProjectToViewModel(TemplateGroup templateGroup) =>
new(templateGroup.Id, templateGroup.Name, templateGroup.Templates.Count);
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -11,7 +12,7 @@ public class GetBlockItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.BlockItems
List<BlockItem> allItems = await dbContext.BlockItems
.AsNoTracking()
.Filter(i => i.BlockId == request.BlockId)
.Include(i => i.Collection)
@@ -30,7 +31,16 @@ public class GetBlockItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.ToListAsync(cancellationToken)
.Map(items => items.Map(Mapper.ProjectToViewModel).ToList());
.ToListAsync(cancellationToken);
if (allItems.All(bi => bi.IncludeInProgramGuide == false))
{
foreach (BlockItem bi in allItems)
{
bi.IncludeInProgramGuide = true;
}
}
return allItems.Map(Mapper.ProjectToViewModel).ToList();
}
}
@@ -64,8 +64,8 @@ public static class BlockPlayoutChangeDetectionTests
List<EffectiveBlock> effectiveBlocks =
[
new EffectiveBlock(block1, blockKey1, GetLocalDate(2024, 1, 17).AddHours(9)),
new EffectiveBlock(block2, blockKey2, GetLocalDate(2024, 1, 17).AddHours(13)),
new EffectiveBlock(block1, blockKey1, GetLocalDate(2024, 1, 17).AddHours(9), 1),
new EffectiveBlock(block2, blockKey2, GetLocalDate(2024, 1, 17).AddHours(13), 2),
];
Map<CollectionKey, string> collectionEtags = LanguageExt.Map<CollectionKey, string>.Empty;
+1
View File
@@ -11,6 +11,7 @@ public class PlayoutItem
public MediaItem MediaItem { get; set; }
public DateTime Start { get; set; }
public DateTime Finish { get; set; }
public DateTime? GuideStart { get; set; }
public DateTime? GuideFinish { get; set; }
public string CustomTitle { get; set; }
public int GuideGroup { get; set; }
@@ -16,4 +16,5 @@ public class BlockItem
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public bool IncludeInProgramGuide { get; set; }
}
@@ -32,8 +32,6 @@ public class BlockPlayoutBuilder(
playout.Channel.Number,
playout.Channel.Name);
var random = new Random();
List<PlaybackOrder> allowedPlaybackOrders =
[
PlaybackOrder.Chronological,
@@ -149,14 +147,16 @@ public class BlockPlayoutBuilder(
Finish = currentTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
FillerKind = FillerKind.None,
FillerKind = blockItem.IncludeInProgramGuide ? FillerKind.None : FillerKind.GuideMode,
//CustomTitle = scheduleItem.CustomTitle,
//WatermarkId = scheduleItem.WatermarkId,
//PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = random.Next(),
GuideGroup = effectiveBlock.TemplateItemId,
GuideStart = effectiveBlock.Start.UtcDateTime,
GuideFinish = blockFinish.UtcDateTime,
BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
CollectionEtag = collectionEtags[collectionKey]
@@ -2,7 +2,7 @@ using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Scheduling.BlockScheduling;
internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset Start)
internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset Start, int TemplateItemId)
{
public static List<EffectiveBlock> GetEffectiveBlocks(
ICollection<PlayoutTemplate> templates,
@@ -27,6 +27,7 @@ internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset St
var newBlocks = playoutTemplate.Template.Items
.Map(i => ToEffectiveBlock(playoutTemplate, i, today, start))
.Map(NormalizeGuideMode)
.ToList();
effectiveBlocks.AddRange(newBlocks);
@@ -56,5 +57,20 @@ internal record EffectiveBlock(Block Block, BlockKey BlockKey, DateTimeOffset St
templateItem.StartTime.Hours,
templateItem.StartTime.Minutes,
0,
start.Offset));
start.Offset),
templateItem.Id);
private static EffectiveBlock NormalizeGuideMode(EffectiveBlock effectiveBlock)
{
if (effectiveBlock.Block.Items is not null &&
effectiveBlock.Block.Items.All(bi => bi.IncludeInProgramGuide == false))
{
foreach (BlockItem blockItem in effectiveBlock.Block.Items)
{
blockItem.IncludeInProgramGuide = true;
}
}
return effectiveBlock;
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_BlockItem_IncludeInProgramGuide : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IncludeInProgramGuide",
table: "BlockItem",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IncludeInProgramGuide",
table: "BlockItem");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItem_GuideStart : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "GuideStart",
table: "PlayoutItem",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GuideStart",
table: "PlayoutItem");
}
}
}
@@ -1470,6 +1470,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("GuideGroup")
.HasColumnType("int");
b.Property<DateTime?>("GuideStart")
.HasColumnType("datetime(6)");
b.Property<TimeSpan>("InPoint")
.HasColumnType("time(6)");
@@ -1895,6 +1898,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("CollectionType")
.HasColumnType("int");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("tinyint(1)");
b.Property<int>("Index")
.HasColumnType("int");
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_BlockItem_IncludeInProgramGuide : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IncludeInProgramGuide",
table: "BlockItem",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IncludeInProgramGuide",
table: "BlockItem");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutItem_GuideStart : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "GuideStart",
table: "PlayoutItem",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GuideStart",
table: "PlayoutItem");
}
}
}
@@ -1468,6 +1468,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("GuideGroup")
.HasColumnType("INTEGER");
b.Property<DateTime?>("GuideStart")
.HasColumnType("TEXT");
b.Property<TimeSpan>("InPoint")
.HasColumnType("TEXT");
@@ -1893,6 +1896,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
+1
View File
@@ -1,6 +1,7 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/CodeAnnotations/NamespacesWithAnnotations/=ErsatzTV_002EAnnotations/@EntryIndexedValue">True</s:Boolean>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=DTO/@EntryIndexedValue">DTO</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=EPG/@EntryIndexedValue">EPG</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=FF/@EntryIndexedValue">FF</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HDHR/@EntryIndexedValue">HDHR</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LE/@EntryIndexedValue">LE</s:String>
+16 -3
View File
@@ -56,6 +56,7 @@
<MudItem xs="8">
<MudTable Class="mt-6" Hover="true" Items="_block.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
<ColGroup>
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
@@ -66,6 +67,7 @@
<HeaderContent>
<MudTh>Collection</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh/>
<MudTh/>
<MudTh/>
@@ -82,6 +84,9 @@
@context.PlaybackOrder
</MudText>
</MudTd>
<MudTd>
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))" />
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))">
@@ -390,7 +395,8 @@
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private void AddBlockItem()
@@ -416,7 +422,8 @@
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem
MediaItem = item.MediaItem,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
foreach (BlockItemEditViewModel i in _block.Items.Filter(bi => bi.Index >= newItem.Index))
@@ -473,7 +480,8 @@
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder)).ToList();
item.PlaybackOrder,
item.IncludeInProgramGuide)).ToList();
_block.Minutes = _durationHours * 60 + _durationMinutes;
@@ -485,4 +493,9 @@
_selectedItem = null;
_previewItems = await Mediator.Send(new PreviewBlockPlayout(GenerateReplaceRequest()), _cts.Token);
}
private static void UpdateEPG(BlockItemEditViewModel context, bool includeInProgramGuide)
{
context.IncludeInProgramGuide = includeInProgramGuide;
}
}
@@ -59,6 +59,8 @@ public class BlockItemEditViewModel : INotifyPropertyChanged
};
public PlaybackOrder PlaybackOrder { get; set; }
public bool IncludeInProgramGuide { get; set; }
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{