From 4c75e638a20c1c929957bbe36c68d7dedc5e3274 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Wed, 18 Jan 2023 14:09:54 -0600 Subject: [PATCH] fix bug with smart collection progress (#1118) --- CHANGELOG.md | 1 + .../Playouts/Commands/BuildPlayoutHandler.cs | 3 + .../Fakes/FakeMediaCollectionRepository.cs | 2 +- .../Scheduling/PlayoutBuilderTests.cs | 164 +++++++++++++++++- .../Scheduling/ScheduleIntegrationTests.cs | 158 ++++++++++++++++- ErsatzTV.Core/Scheduling/PlayoutBuilder.cs | 21 ++- ErsatzTV.Infrastructure/Search/SearchIndex.cs | 2 +- ErsatzTV.Scanner/ErsatzTV.Scanner.csproj | 2 + ErsatzTV.sln | 8 +- 9 files changed, 344 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 815453ae0..3d0fb8137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed - Fix schedule editor crashing due to bad music video artist data +- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections ### Changed - Always use software pipeline for error display diff --git a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs index 43be14239..c24cf8149 100644 --- a/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs @@ -105,8 +105,11 @@ public class BuildPlayoutHandler : IRequestHandler ps.Items) .ThenInclude(psi => psi.FallbackFiller) + .Include(p => p.ProgramScheduleAnchors) + .ThenInclude(psa => psa.EnumeratorState) .Include(p => p.ProgramScheduleAnchors) .ThenInclude(a => a.MediaItem) + .Include(p => p.ProgramSchedule) .ThenInclude(ps => ps.Items) .ThenInclude(psi => psi.Collection) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index 1c0101983..f338664c6 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -15,7 +15,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository public Task> GetItems(int id) => _data[id].ToList().AsTask(); public Task> GetMultiCollectionItems(int id) => throw new NotSupportedException(); - public Task> GetSmartCollectionItems(int id) => throw new NotSupportedException(); + public Task> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask(); public Task> GetMultiCollectionCollections(int id) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs index afa42c345..1575cf007 100644 --- a/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs @@ -2380,6 +2380,91 @@ public class PlayoutBuilderTests // the continue anchor should have the same seed as the most recent (last) checkpoint from the first run firstSeedValue.Should().Be(secondSeedValue); } + + [Test] + public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed() + { + var mediaItems = new List + { + TestMovie(1, TimeSpan.FromHours(1), DateTime.Today), + TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)), + TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(3)) + }; + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle); + DateTimeOffset start = HoursAfterMidnight(0); + DateTimeOffset finish = start + TimeSpan.FromHours(6); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + result.Items.Count.Should().Be(6); + result.ProgramScheduleAnchors.Count.Should().Be(2); + PlayoutProgramScheduleAnchor primaryAnchor = result.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1); + primaryAnchor.EnumeratorState.Seed.Should().BeGreaterThan(0); + primaryAnchor.EnumeratorState.Index.Should().Be(0); + + int firstSeedValue = primaryAnchor.EnumeratorState.Seed; + + DateTimeOffset start2 = HoursAfterMidnight(0); + DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6); + + Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2); + + primaryAnchor = result2.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1); + int secondSeedValue = primaryAnchor.EnumeratorState.Seed; + + firstSeedValue.Should().Be(secondSeedValue); + + primaryAnchor.EnumeratorState.Index.Should().Be(0); + } + + [Test] + public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed_MultipleDays() + { + var mediaItems = new List(); + for (int i = 1; i <= 100; i++) + { + mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i))); + } + + (PlayoutBuilder builder, Playout playout) = + TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle); + DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5); + DateTimeOffset finish = start + TimeSpan.FromDays(2); + + Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + result.Items.Count.Should().Be(53); + result.ProgramScheduleAnchors.Count.Should().Be(4); + + result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue(); + PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors + .Filter(psa => psa.SmartCollectionId == 1) + .OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue) + .First(); + lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0); + lastCheckpoint.EnumeratorState.Index.Should().Be(53); + + int firstSeedValue = lastCheckpoint.EnumeratorState.Seed; + + for (var i = 1; i < 20; i++) + { + DateTimeOffset start2 = start.AddHours(i); + DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2); + + Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2); + + PlayoutProgramScheduleAnchor continueAnchor = + result2.ProgramScheduleAnchors + .Filter(psa => psa.SmartCollectionId == 1) + .First(x => x.AnchorDate is null); + int secondSeedValue = continueAnchor.EnumeratorState.Seed; + + // the continue anchor should have the same seed as the most recent (last) checkpoint from the first run + firstSeedValue.Should().Be(secondSeedValue); + } + } [Test] public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor() @@ -2727,10 +2812,34 @@ public class PlayoutBuilderTests { Id = 1, Index = 1, + CollectionType = ProgramScheduleItemCollectionType.Collection, Collection = mediaCollection, CollectionId = mediaCollection.Id, StartTime = null, - PlaybackOrder = playbackOrder + PlaybackOrder = playbackOrder, + }; + + private static ProgramScheduleItem Flood( + SmartCollection smartCollection, + SmartCollection fillerCollection, + PlaybackOrder playbackOrder) => + new ProgramScheduleItemFlood + { + Id = 1, + Index = 1, + CollectionType = ProgramScheduleItemCollectionType.SmartCollection, + SmartCollection = smartCollection, + SmartCollectionId = smartCollection.Id, + StartTime = null, + PlaybackOrder = playbackOrder, + FallbackFiller = new FillerPreset + { + Id = 1, + CollectionType = ProgramScheduleItemCollectionType.SmartCollection, + SmartCollection = fillerCollection, + SmartCollectionId = fillerCollection.Id, + FillerKind = FillerKind.Fallback + } }; private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) => @@ -2791,6 +2900,59 @@ public class PlayoutBuilderTests return new TestData(builder, playout); } + + private TestData TestDataFloodForSmartCollectionItems( + List mediaItems, + PlaybackOrder playbackOrder, + Mock configMock = null) + { + var mediaCollection = new SmartCollection + { + Id = 1, + Query = "asdf" + }; + + var fillerCollection = new SmartCollection + { + Id = 2, + Query = "ghjk" + }; + + Mock configRepo = configMock ?? new Mock(); + + var collectionRepo = new FakeMediaCollectionRepository( + Map( + (mediaCollection.Id, mediaItems), + (fillerCollection.Id, mediaItems.Take(1).ToList()) + ) + ); + var televisionRepo = new FakeTelevisionRepository(); + var artistRepo = new Mock(); + var factory = new Mock(); + var localFileSystem = new Mock(); + var builder = new PlayoutBuilder( + configRepo.Object, + collectionRepo, + televisionRepo, + artistRepo.Object, + factory.Object, + localFileSystem.Object, + _logger); + + var items = new List { Flood(mediaCollection, fillerCollection, playbackOrder) }; + + var playout = new Playout + { + Id = 1, + ProgramSchedule = new ProgramSchedule { Items = items }, + Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }, + Items = new List(), + ProgramScheduleAnchors = new List(), + ProgramScheduleAlternates = new List() + }; + + return new TestData(builder, playout); + } private record TestData(PlayoutBuilder Builder, Playout Playout); } diff --git a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs index 6fd9ea715..cf2798b73 100644 --- a/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs +++ b/ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs @@ -3,12 +3,17 @@ using Dapper; using Destructurama; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Repositories.Caching; using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Infrastructure.Data.Repositories.Caching; using ErsatzTV.Infrastructure.Extensions; +using ErsatzTV.Infrastructure.Search; using LanguageExt.UnsafeValueAccess; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -28,7 +33,7 @@ public class ScheduleIntegrationTests public ScheduleIntegrationTests() { Log.Logger = new LoggerConfiguration() - .MinimumLevel.Information() + .MinimumLevel.Debug() .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning) .WriteTo.Console() @@ -37,7 +42,123 @@ public class ScheduleIntegrationTests } [Test] - public async Task Test() + public async Task TestExistingData() + { + const string DB_FILE_NAME = "/tmp/whatever.sqlite3"; + const int PLAYOUT_ID = 39; + + var start = new DateTimeOffset(2023, 1, 18, 11, 0, 0, TimeSpan.FromHours(-5)); + DateTimeOffset finish = start.AddDays(2); + + IServiceCollection services = new ServiceCollection() + .AddLogging(); + + var connectionString = $"Data Source={DB_FILE_NAME};foreign keys=true;"; + + services.AddDbContext( + options => options.UseSqlite( + connectionString, + o => + { + o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + o.MigrationsAssembly("ErsatzTV.Infrastructure"); + }), + ServiceLifetime.Scoped, + ServiceLifetime.Singleton); + + services.AddDbContextFactory( + options => options.UseSqlite( + connectionString, + o => + { + o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + o.MigrationsAssembly("ErsatzTV.Infrastructure"); + })); + + SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); + SqlMapper.AddTypeHandler(new GuidHandler()); + SqlMapper.AddTypeHandler(new TimeSpanHandler()); + + services.AddSingleton((Func)(_ => new SerilogLoggerFactory())); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + + services.AddSingleton(_ => new Mock().Object); + + ServiceProvider provider = services.BuildServiceProvider(); + + IDbContextFactory factory = provider.GetRequiredService>(); + + ILogger logger = provider.GetRequiredService>(); + logger.LogInformation("Database is at {File}", DB_FILE_NAME); + + await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None); + await dbContext.Database.MigrateAsync(CancellationToken.None); + await DbInitializer.Initialize(dbContext, CancellationToken.None); + + ISearchIndex searchIndex = provider.GetRequiredService(); + await searchIndex.Initialize( + new LocalFileSystem( + provider.GetRequiredService(), + provider.GetRequiredService>()), + provider.GetRequiredService()); + + await searchIndex.Rebuild( + provider.GetRequiredService(), + provider.GetRequiredService()); + + var builder = new PlayoutBuilder( + new ConfigElementRepository(factory), + new MediaCollectionRepository(new Mock().Object, searchIndex, factory), + new TelevisionRepository(factory), + new ArtistRepository(factory), + new Mock().Object, + new Mock().Object, + provider.GetRequiredService>()); + + { + await using TvContext context = await factory.CreateDbContextAsync(); + + Option maybePlayout = await GetPlayout(context, PLAYOUT_ID); + Playout playout = maybePlayout.ValueUnsafe(); + + await builder.Build(playout, PlayoutBuildMode.Reset, start, finish); + + await context.SaveChangesAsync(); + } + + for (var i = 1; i <= (24 * 1); i++) + { + await using TvContext context = await factory.CreateDbContextAsync(); + + Option maybePlayout = await GetPlayout(context, PLAYOUT_ID); + Playout playout = maybePlayout.ValueUnsafe(); + + await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i)); + + await context.SaveChangesAsync(); + } + + for (var i = 25; i <= 26; i++) + { + await using TvContext context = await factory.CreateDbContextAsync(); + + Option maybePlayout = await GetPlayout(context, PLAYOUT_ID); + Playout playout = maybePlayout.ValueUnsafe(); + + await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i)); + + await context.SaveChangesAsync(); + } + } + + [Test] + public async Task TestMockData() { string dbFileName = Path.GetTempFileName() + ".sqlite3"; @@ -221,8 +342,41 @@ public class ScheduleIntegrationTests return await dbContext.Playouts .Include(p => p.Channel) .Include(p => p.Items) + + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.Collection) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.MediaItem) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.PreRollFiller) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.MidRollFiller) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.PostRollFiller) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.TailFiller) + .Include(p => p.ProgramScheduleAlternates) + .ThenInclude(a => a.ProgramSchedule) + .ThenInclude(ps => ps.Items) + .ThenInclude(psi => psi.FallbackFiller) + + .Include(p => p.ProgramScheduleAnchors) + .ThenInclude(a => a.EnumeratorState) .Include(p => p.ProgramScheduleAnchors) .ThenInclude(a => a.MediaItem) + .Include(p => p.ProgramSchedule) .ThenInclude(ps => ps.Items) .ThenInclude(psi => psi.Collection) diff --git a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs index 8d88c54be..d5807f755 100644 --- a/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs +++ b/ErsatzTV.Core/Scheduling/PlayoutBuilder.cs @@ -668,6 +668,8 @@ public class PlayoutBuilder : IPlayoutBuilder a => a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.CollectionId && a.MediaItemId == collectionKey.MediaItemId + && a.SmartCollectionId == collectionKey.SmartCollectionId + && a.MultiCollectionId == collectionKey.MultiCollectionId && a.AnchorDate is null); var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State); @@ -727,15 +729,18 @@ public class PlayoutBuilder : IPlayoutBuilder && a.SmartCollectionId == collectionKey.SmartCollectionId && a.MediaItemId == collectionKey.MediaItemId); - // foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor) - // { - // _logger.LogDebug("Selecting anchor {@Anchor}", anchor); - // } + CollectionEnumeratorState state = null; + + foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor) + { + // _logger.LogDebug("Selecting anchor {@Anchor}", anchor); + + anchor.EnumeratorState ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; + + state = anchor.EnumeratorState; + } - CollectionEnumeratorState state = maybeAnchor.Match( - anchor => anchor.EnumeratorState ?? - (anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }), - () => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }); + state ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; int collectionId = collectionKey.CollectionId ?? 0; diff --git a/ErsatzTV.Infrastructure/Search/SearchIndex.cs b/ErsatzTV.Infrastructure/Search/SearchIndex.cs index 11723dcb1..9bbca004f 100644 --- a/ErsatzTV.Infrastructure/Search/SearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/SearchIndex.cs @@ -184,7 +184,7 @@ public sealed class SearchIndex : ISearchIndex { "searchField", searchField } }; - client.Breadcrumbs.Leave("SearchIndex.Search", BreadcrumbType.State, metadata); + client?.Breadcrumbs?.Leave("SearchIndex.Search", BreadcrumbType.State, metadata); if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)) || _writer.MaxDoc == 0) diff --git a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj index fa10d9047..55ed59508 100644 --- a/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj +++ b/ErsatzTV.Scanner/ErsatzTV.Scanner.csproj @@ -5,6 +5,8 @@ net7.0 enable enable + Debug;Release;Debug No Sync + AnyCPU diff --git a/ErsatzTV.sln b/ErsatzTV.sln index da45e61b6..c91088d83 100644 --- a/ErsatzTV.sln +++ b/ErsatzTV.sln @@ -33,8 +33,8 @@ Global {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug No Sync|Any CPU + {E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug No Sync|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -77,12 +77,12 @@ Global {591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|Any CPU.Build.0 = Release|Any CPU {591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU {591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU - {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU + {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug No Sync|Any CPU + {5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug No Sync|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU