diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c79b01c..af8866d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Restore default UI font that was erroneously removed in v26.1.1 - Use configured searching log level on startup, instead of the default log level of `Information` - MySql: fix searching for shows and seasons in schedule items editor +- Fix 500 errors when serving XMLTV due to concurrent file reads and writes ## [26.1.1] - 2026-01-08 ### Fixed diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs index ed654a1c7..afaebd747 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs @@ -11,30 +11,18 @@ using Microsoft.IO; namespace ErsatzTV.Application.Channels; -public partial class GetChannelGuideHandler : IRequestHandler> +public partial class GetChannelGuideHandler( + IDbContextFactory dbContextFactory, + RecyclableMemoryStreamManager recyclableMemoryStreamManager, + IFileSystem fileSystem, + ILocalFileSystem localFileSystem) + : IRequestHandler> { - private readonly IDbContextFactory _dbContextFactory; - private readonly ILocalFileSystem _localFileSystem; - private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; - private readonly IFileSystem _fileSystem; - - public GetChannelGuideHandler( - IDbContextFactory dbContextFactory, - RecyclableMemoryStreamManager recyclableMemoryStreamManager, - IFileSystem fileSystem, - ILocalFileSystem localFileSystem) - { - _dbContextFactory = dbContextFactory; - _recyclableMemoryStreamManager = recyclableMemoryStreamManager; - _fileSystem = fileSystem; - _localFileSystem = localFileSystem; - } - public async Task> Handle( GetChannelGuide request, CancellationToken cancellationToken) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var hiddenChannelNumbers = dbContext.Channels .Where(c => c.ShowInEpg == false) .Select(c => c.Number) @@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler $"{n}.xml") .ToImmutableHashSet(); - string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); - if (!_fileSystem.File.Exists(channelsFile)) + string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); + if (!fileSystem.File.Exists(channelsFile)) { return BaseError.New($"Required file {channelsFile} is missing"); } - long mtime = File.GetLastWriteTime(channelsFile).Ticks; + long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks; var accessTokenUri = $"?v={mtime}"; if (!string.IsNullOrWhiteSpace(request.AccessToken)) @@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler(); - foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)) + foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)) { if (fileName.Contains("channels")) { continue; } - if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName))) + if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName))) { continue; } - string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken); + try + { + string channelDataFragment = await SharedReadAllText(fileName, cancellationToken); - channelDataFragment = channelDataFragment - .Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") - .Replace("{AccessTokenUri}", accessTokenUri); + channelDataFragment = channelDataFragment + .Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") + .Replace("{AccessTokenUri}", accessTokenUri); - channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty); + channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty); - channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment); + channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment); + } + catch (FileNotFoundException) + { + // ignore this channel fragment + } + catch (IOException) + { + // ignore this channel fragment + } } - return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments); + return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments); + } + + private async Task SharedReadAllText(string fileName, CancellationToken cancellationToken) + { + await using var stream = fileSystem.FileStream.New( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(cancellationToken); } [GeneratedRegex(@"]+?>.*?<\/etv:[^>]+?>|]+?\/>", RegexOptions.Singleline)]