-
Notifications
You must be signed in to change notification settings - Fork 0
[ADR-178] ICloudAsset abstraction, JsonCloudAsset<T>, and stub file downloader #148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| namespace AdaptiveRemote.Services.CloudAssets; | ||
|
|
||
| internal abstract class BasicCloudAsset<T> : ICloudAsset<T> | ||
| { | ||
| public string Name { get; } | ||
| public string StreamUrl { get; } | ||
| public string EventName { get; } | ||
| public string ResourcePath { get; } | ||
|
|
||
| protected BasicCloudAsset(string name, string streamUrl, string eventName, string resourcePath) | ||
| { | ||
| Name = name; | ||
| StreamUrl = streamUrl; | ||
| EventName = eventName; | ||
| ResourcePath = resourcePath; | ||
| } | ||
|
|
||
| public abstract Task<object> DeserializeAsync(Stream stream, CancellationToken ct); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,71 +1,53 @@ | ||||||||
| using AdaptiveRemote.Contracts; | ||||||||
| using AdaptiveRemote.Logging; | ||||||||
| using AdaptiveRemote.Services.Lifecycle; | ||||||||
| using Microsoft.Extensions.Hosting; | ||||||||
| using Microsoft.Extensions.Logging; | ||||||||
|
|
||||||||
| namespace AdaptiveRemote.Services.CloudAssets; | ||||||||
|
|
||||||||
| internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer | ||||||||
| { | ||||||||
| private readonly IEnumerable<ICloudAsset> _assets; | ||||||||
| private readonly ICloudAssetDownloader _downloader; | ||||||||
| private readonly ICloudAssetStore _store; | ||||||||
| private readonly MessageLogger _log; | ||||||||
| private readonly TaskCompletionSource _initCompleted = new(); | ||||||||
|
|
||||||||
| public CloudAssetOrchestrator(ICloudAssetStore store) | ||||||||
| public CloudAssetOrchestrator( | ||||||||
| IEnumerable<ICloudAsset> assets, | ||||||||
| ICloudAssetDownloader downloader, | ||||||||
| ICloudAssetStore store, | ||||||||
| ILogger<CloudAssetOrchestrator> logger) | ||||||||
| { | ||||||||
| _assets = assets; | ||||||||
| _downloader = downloader; | ||||||||
| _store = store; | ||||||||
| _log = new MessageLogger(logger); | ||||||||
| } | ||||||||
|
|
||||||||
| protected override Task ExecuteAsync(CancellationToken stoppingToken) | ||||||||
| protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||||||
| { | ||||||||
| CompiledLayout layout = new( | ||||||||
| Id: Guid.Empty, | ||||||||
| RawLayoutId: Guid.Empty, | ||||||||
| UserId: "stub", | ||||||||
| IsActive: true, | ||||||||
| Version: 1, | ||||||||
| Elements: | ||||||||
| [ | ||||||||
| new LayoutGroupDefinitionDto("DPAD", | ||||||||
| [ | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "Left"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "Right"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "Back"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "Power"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "PowerOn"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "PowerOff"), | ||||||||
| ]), | ||||||||
| new LayoutGroupDefinitionDto("WELL", | ||||||||
| [ | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TiVo"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "Netflix"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "Guide"), | ||||||||
| ]), | ||||||||
| new LayoutGroupDefinitionDto("PLAYBACK", | ||||||||
| [ | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "Pause"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "Record"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "Skip"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "Replay"), | ||||||||
| ]), | ||||||||
| new LayoutGroupDefinitionDto("CHANNELANDVOLUME", | ||||||||
| [ | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "ChannelUp"), | ||||||||
| new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "ChannelDown"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VolumeDown"), | ||||||||
| new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "Mute"), | ||||||||
| ]), | ||||||||
| ], | ||||||||
| CssDefinitions: "", | ||||||||
| CompiledAt: DateTimeOffset.UtcNow); | ||||||||
|
|
||||||||
| _store.SetLayout(layout); | ||||||||
| _initCompleted.SetResult(); | ||||||||
|
|
||||||||
| return Task.CompletedTask; | ||||||||
| try | ||||||||
| { | ||||||||
| foreach (ICloudAsset asset in _assets) | ||||||||
| { | ||||||||
| _log.CloudAssetOrchestrator_Downloading(asset.Name); | ||||||||
| Stream stream = await _downloader.GetActiveAsync(asset.ResourcePath, stoppingToken) | ||||||||
| ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); | ||||||||
|
||||||||
| ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); | |
| ?? throw new InvalidOperationException( | |
| $"Failed to download asset '{asset.Name}' for resource path '{asset.ResourcePath}'. Downloader: '{_downloader.GetType().FullName}'."); |
Copilot
AI
Apr 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CloudAssetOrchestrator.ExecuteAsync now contains the core initialization logic (download → parse → store) and is a startup gate via IPreScopeInitializer, but there are no unit tests covering success and failure paths (e.g., null stream, parse failure, cancellation). Adding tests would help prevent regressions since failures here block the app from reaching Ready phase.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,12 @@ | ||
| namespace AdaptiveRemote.Services.CloudAssets; | ||
|
|
||
| /// <summary> | ||
| /// Shared connection and auth settings for all cloud asset services. | ||
| /// Shared settings for all cloud asset services. | ||
| /// </summary> | ||
| internal class CloudSettings | ||
| { | ||
| public string BackendBaseUrl { get; set; } = ""; | ||
| public string CognitoTokenEndpointUrl { get; set; } = ""; | ||
| public string ClientId { get; set; } = ""; | ||
| public string ClientSecret { get; set; } = ""; | ||
| public int IdleCooldownSeconds { get; set; } = 30; | ||
| public int SseMaxConsecutiveFailures { get; set; } = 10; | ||
| public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets"; | ||
| public string StubFilePath { get; set; } = "dev/layout.json"; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,29 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| using AdaptiveRemote.Services; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| using Microsoft.Extensions.Options; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| namespace AdaptiveRemote.Services.CloudAssets; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| internal sealed class FileCloudAssetDownloader : ICloudAssetDownloader | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly CloudSettings _settings; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private readonly IFileSystem _fileSystem; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public FileCloudAssetDownloader(IOptions<CloudSettings> options, IFileSystem fileSystem) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _settings = options.Value; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _fileSystem = fileSystem; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Task<Stream?> GetActiveAsync(string resourcePath, CancellationToken ct) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!_fileSystem.FileExists(path)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Task.FromResult<Stream?>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Task.FromResult<Stream?>(_fileSystem.OpenRead(path)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| => Task.FromResult<Stream?>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+19
to
+28
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); | |
| if (!_fileSystem.FileExists(path)) | |
| { | |
| return Task.FromResult<Stream?>(null); | |
| } | |
| return Task.FromResult<Stream?>(_fileSystem.OpenRead(path)); | |
| } | |
| public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) | |
| => Task.FromResult<Stream?>(null); | |
| string path = GetResolvedStubFilePath(); | |
| if (!_fileSystem.FileExists(path)) | |
| { | |
| return Task.FromResult<Stream?>(null); | |
| } | |
| return Task.FromResult<Stream?>(_fileSystem.OpenRead(path)); | |
| } | |
| public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) | |
| => Task.FromResult<Stream?>(null); | |
| private string GetResolvedStubFilePath() | |
| { | |
| if (string.IsNullOrWhiteSpace(_settings.StubFilePath)) | |
| { | |
| throw new InvalidOperationException("CloudSettings.StubFilePath must be configured with a non-empty file path."); | |
| } | |
| string expandedPath = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); | |
| if (string.IsNullOrWhiteSpace(expandedPath)) | |
| { | |
| throw new InvalidOperationException("CloudSettings.StubFilePath resolved to an empty file path after environment variable expansion."); | |
| } | |
| return Path.IsPathRooted(expandedPath) | |
| ? Path.GetFullPath(expandedPath) | |
| : Path.GetFullPath(expandedPath, AppContext.BaseDirectory); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
|
|
||
| namespace AdaptiveRemote.Services.CloudAssets; | ||
|
|
||
| internal sealed class JsonCloudAsset<T>( | ||
| string name, | ||
| string streamUrl, | ||
| string eventName, | ||
| string resourcePath, | ||
| JsonSerializerContext jsonContext) | ||
| : BasicCloudAsset<T>(name, streamUrl, eventName, resourcePath) | ||
| { | ||
| public override async Task<object> DeserializeAsync(Stream stream, CancellationToken ct) | ||
| { | ||
| object? result = await JsonSerializer.DeserializeAsync(stream, typeof(T), jsonContext, ct); | ||
| return result ?? throw new InvalidOperationException( | ||
| $"Deserialized null for asset '{Name}'."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "CloudSettings": { | ||
| "StubFilePath": "dev/layout.json" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| { | ||
| "id": "00000000-0000-0000-0000-000000000000", | ||
| "rawLayoutId": "00000000-0000-0000-0000-000000000000", | ||
| "userId": "stub", | ||
| "isActive": true, | ||
| "version": 1, | ||
| "elements": [ | ||
| { | ||
| "$type": "group", | ||
| "cssId": "DPAD", | ||
| "children": [ | ||
| { "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" }, | ||
| { "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" }, | ||
| { "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" }, | ||
| { "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" } | ||
| ] | ||
| }, | ||
| { | ||
| "$type": "group", | ||
| "cssId": "WELL", | ||
| "children": [ | ||
| { "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" } | ||
| ] | ||
| }, | ||
| { | ||
| "$type": "group", | ||
| "cssId": "PLAYBACK", | ||
| "children": [ | ||
| { "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" }, | ||
| { "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" } | ||
| ] | ||
| }, | ||
| { | ||
| "$type": "group", | ||
| "cssId": "CHANNELANDVOLUME", | ||
| "children": [ | ||
| { "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" }, | ||
| { "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" }, | ||
| { "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" }, | ||
| { "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" }, | ||
| { "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" } | ||
| ] | ||
| } | ||
| ], | ||
| "cssDefinitions": "", | ||
| "compiledAt": "2026-04-19T00:00:00+00:00" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,5 +7,8 @@ | |
| "clientSecret": "", | ||
| "scope": "" | ||
| } | ||
| }, | ||
| "CloudSettings": { | ||
| "StubFilePath": "dev/layout.json" | ||
| } | ||
|
Comment on lines
+11
to
13
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AddCloudAssetServicesregistersFileCloudAssetDownloaderas the onlyICloudAssetDownloader, butCloudSettings.StubFilePathdefaults to an empty string andsrc/AdaptiveRemote/appsettings.jsonhas noCloudSettingssection. In non-Development environments this makesGetActiveAsyncreturn null andCloudAssetOrchestratorthrows, preventing the app from starting. Consider making downloader selection conditional (stub downloader only whenStubFilePathis configured) and/or providing a non-empty default/fallback downloader so Production startup isn't broken.