From 1d9224a0257e3489bcb39d04bbf615e3f3974266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Wed, 18 Feb 2026 14:36:57 +0300 Subject: [PATCH] qwen edit --- BookReader/MauiProgram.cs | 2 + BookReader/Models/Book.cs | 5 +- BookReader/Models/CalibreBook.cs | 8 +- BookReader/Services/BookParserService.cs | 16 ++- BookReader/Services/CalibreWebService.cs | 100 +++++++++----- BookReader/Services/ICoverCacheService.cs | 8 ++ BookReader/Services/IImageLoadingService.cs | 10 ++ BookReader/Services/ImageLoadingService.cs | 50 +++++++ BookReader/Services/LruCoverCacheService.cs | 123 ++++++++++++++++++ BookReader/ViewModels/BookshelfViewModel.cs | 5 +- .../ViewModels/CalibreLibraryViewModel.cs | 5 +- BookReader/Views/BookshelfPage.xaml.cs | 2 +- BookReader/Views/CalibreLibraryPage.xaml.cs | 3 +- 13 files changed, 292 insertions(+), 45 deletions(-) create mode 100644 BookReader/Services/ICoverCacheService.cs create mode 100644 BookReader/Services/IImageLoadingService.cs create mode 100644 BookReader/Services/ImageLoadingService.cs create mode 100644 BookReader/Services/LruCoverCacheService.cs diff --git a/BookReader/MauiProgram.cs b/BookReader/MauiProgram.cs index c766326..da09af1 100644 --- a/BookReader/MauiProgram.cs +++ b/BookReader/MauiProgram.cs @@ -31,6 +31,8 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // HTTP Client for Calibre builder.Services.AddHttpClient(); diff --git a/BookReader/Models/Book.cs b/BookReader/Models/Book.cs index 686c556..0066e51 100644 --- a/BookReader/Models/Book.cs +++ b/BookReader/Models/Book.cs @@ -36,6 +36,9 @@ public class Book public string? CalibreId { get; set; } // ID from Calibre-Web if downloaded from there + [Ignore] + public string? CoverCacheKey => $"book_{Id}_cover"; + [Ignore] public ImageSource? CoverImageSource { @@ -45,7 +48,7 @@ public class Book { return ImageSource.FromStream(() => new MemoryStream(CoverImage)); } - return ImageSource.FromFile("default_cover.png"); + return null; } } diff --git a/BookReader/Models/CalibreBook.cs b/BookReader/Models/CalibreBook.cs index 8596d4e..cd7a869 100644 --- a/BookReader/Models/CalibreBook.cs +++ b/BookReader/Models/CalibreBook.cs @@ -1,4 +1,6 @@ -namespace BookReader.Models; +using SQLite; + +namespace BookReader.Models; public class CalibreBook { @@ -10,6 +12,10 @@ public class CalibreBook public string Format { get; set; } = string.Empty; public byte[]? CoverImage { get; set; } + [Ignore] + public string CoverCacheKey => $"calibre_{Id}_cover"; + + [Ignore] public ImageSource? CoverImageSource { get diff --git a/BookReader/Services/BookParserService.cs b/BookReader/Services/BookParserService.cs index ba27d2b..320ac56 100644 --- a/BookReader/Services/BookParserService.cs +++ b/BookReader/Services/BookParserService.cs @@ -27,12 +27,20 @@ public class BookParserService : IBookParserService var bookId = Guid.NewGuid().ToString(); var destPath = Path.Combine(_booksDir, $"{bookId}{extension}"); - // Copy file to app storage + // Перемещаем файл вместо копирования для экономии памяти if (sourceFilePath != destPath) { - using var sourceStream = File.OpenRead(sourceFilePath); - using var destStream = File.Create(destPath); - await sourceStream.CopyToAsync(destStream); + try + { + File.Move(sourceFilePath, destPath, overwrite: true); + } + catch (IOException) + { + // Если перемещение не удалось (например, файл на внешнем носителе), копируем + using var sourceStream = File.OpenRead(sourceFilePath); + using var destStream = File.Create(destPath); + await sourceStream.CopyToAsync(destStream); + } } var book = new Book diff --git a/BookReader/Services/CalibreWebService.cs b/BookReader/Services/CalibreWebService.cs index 1e2d475..5c210eb 100644 --- a/BookReader/Services/CalibreWebService.cs +++ b/BookReader/Services/CalibreWebService.cs @@ -8,13 +8,15 @@ namespace BookReader.Services; public class CalibreWebService : ICalibreWebService { private readonly HttpClient _httpClient; + private readonly ICoverCacheService _coverCacheService; private string _baseUrl = string.Empty; private string _username = string.Empty; private string _password = string.Empty; - public CalibreWebService(HttpClient httpClient) + public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService) { _httpClient = httpClient; + _coverCacheService = coverCacheService; _httpClient.Timeout = TimeSpan.FromSeconds(Constants.Network.HttpClientTimeoutSeconds); } @@ -61,44 +63,23 @@ public class CalibreWebService : ICalibreWebService var bookIds = json["book_ids"]?.ToObject>() ?? new List(); - foreach (var bookId in bookIds) + // Параллельная загрузка данных книг с ограничением + var semaphore = new SemaphoreSlim(4); // Максимум 4 параллельных запроса + var tasks = bookIds.Select(async bookId => { + await semaphore.WaitAsync(); try { - var bookUrl = $"{_baseUrl}/ajax/book/{bookId}"; - var bookResponse = await _httpClient.GetStringAsync(bookUrl); - var bookJson = JObject.Parse(bookResponse); - - var formats = bookJson["formats"]?.ToObject>() ?? new List(); - var supportedFormat = formats.FirstOrDefault(f => - f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) || - f.Equals("FB2", StringComparison.OrdinalIgnoreCase)); - - if (supportedFormat == null) continue; - - var authors = bookJson["authors"]?.ToObject>() ?? new List(); - - var calibreBook = new CalibreBook - { - Id = bookId.ToString(), - Title = bookJson["title"]?.ToString() ?? "Unknown", - Author = string.Join(", ", authors), - Format = supportedFormat.ToLowerInvariant(), - CoverUrl = $"{_baseUrl}/get/cover/{bookId}", - DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}" - }; - - // Try to load cover - try - { - calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl); - } - catch { } - - books.Add(calibreBook); + return await LoadBookDataAsync(bookId); } - catch { continue; } - } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + books.AddRange(results.Where(b => b != null)!); } catch (Exception ex) { @@ -108,6 +89,55 @@ public class CalibreWebService : ICalibreWebService return books; } + private async Task LoadBookDataAsync(int bookId) + { + try + { + var bookUrl = $"{_baseUrl}/ajax/book/{bookId}"; + var bookResponse = await _httpClient.GetStringAsync(bookUrl); + var bookJson = JObject.Parse(bookResponse); + + var formats = bookJson["formats"]?.ToObject>() ?? new List(); + var supportedFormat = formats.FirstOrDefault(f => + f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) || + f.Equals("FB2", StringComparison.OrdinalIgnoreCase)); + + if (supportedFormat == null) return null; + + var authors = bookJson["authors"]?.ToObject>() ?? new List(); + + var calibreBook = new CalibreBook + { + Id = bookId.ToString(), + Title = bookJson["title"]?.ToString() ?? "Unknown", + Author = string.Join(", ", authors), + Format = supportedFormat.ToLowerInvariant(), + CoverUrl = $"{_baseUrl}/get/cover/{bookId}", + DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}" + }; + + // Try to load cover from cache first + var cacheKey = $"cover_{bookId}"; + calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey); + + if (calibreBook.CoverImage == null) + { + try + { + calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl); + await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage); + } + catch { } + } + + return calibreBook; + } + catch + { + return null; + } + } + public async Task DownloadBookAsync(CalibreBook book, IProgress? progress = null) { var booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books"); diff --git a/BookReader/Services/ICoverCacheService.cs b/BookReader/Services/ICoverCacheService.cs new file mode 100644 index 0000000..baada48 --- /dev/null +++ b/BookReader/Services/ICoverCacheService.cs @@ -0,0 +1,8 @@ +namespace BookReader.Services; + +public interface ICoverCacheService +{ + Task GetCoverAsync(string key); + Task SetCoverAsync(string key, byte[]? coverData); + Task ClearAsync(); +} diff --git a/BookReader/Services/IImageLoadingService.cs b/BookReader/Services/IImageLoadingService.cs new file mode 100644 index 0000000..9612691 --- /dev/null +++ b/BookReader/Services/IImageLoadingService.cs @@ -0,0 +1,10 @@ +namespace BookReader.Services; + +/// +/// Сервис для оптимизированной загрузки изображений с кэшированием +/// +public interface ICachedImageLoadingService +{ + Task LoadImageAsync(byte[]? imageData, string cacheKey); + void ClearCache(); +} diff --git a/BookReader/Services/ImageLoadingService.cs b/BookReader/Services/ImageLoadingService.cs new file mode 100644 index 0000000..d570d43 --- /dev/null +++ b/BookReader/Services/ImageLoadingService.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; + +namespace BookReader.Services; + +/// +/// Сервис для оптимизированной загрузки изображений с кэшированием ImageSource +/// +public class CachedImageLoadingService : ICachedImageLoadingService +{ + private readonly ConcurrentDictionary _memoryCache = new(); + private readonly SemaphoreSlim _semaphore = new(3); // Максимум 3 параллельных загрузки + + public async Task LoadImageAsync(byte[]? imageData, string cacheKey) + { + if (imageData == null || imageData.Length == 0) + return null; + + // Try memory cache first + if (_memoryCache.TryGetValue(cacheKey, out var cachedSource)) + { + return cachedSource; + } + + // Limit concurrent loads + await _semaphore.WaitAsync(); + try + { + // Double-check after acquiring semaphore + if (_memoryCache.TryGetValue(cacheKey, out cachedSource)) + { + return cachedSource; + } + + // Create new ImageSource + var imageSource = ImageSource.FromStream(() => new MemoryStream(imageData)); + _memoryCache[cacheKey] = imageSource; + + return imageSource; + } + finally + { + _semaphore.Release(); + } + } + + public void ClearCache() + { + _memoryCache.Clear(); + } +} diff --git a/BookReader/Services/LruCoverCacheService.cs b/BookReader/Services/LruCoverCacheService.cs new file mode 100644 index 0000000..9908690 --- /dev/null +++ b/BookReader/Services/LruCoverCacheService.cs @@ -0,0 +1,123 @@ +using System.Collections.Concurrent; + +namespace BookReader.Services; + +public class LruCoverCacheService : ICoverCacheService +{ + private readonly ConcurrentDictionary _cache = new(); + private readonly Queue _accessOrder = new(); + private readonly int _maxSize; + private readonly string _cacheDir; + + public LruCoverCacheService(int maxSize = 100) + { + _maxSize = maxSize; + _cacheDir = Path.Combine(FileSystem.AppDataDirectory, "cover_cache"); + Directory.CreateDirectory(_cacheDir); + } + + public async Task GetCoverAsync(string key) + { + // Try memory cache first + if (_cache.TryGetValue(key, out var cached)) + { + UpdateAccessOrder(key); + return cached; + } + + // Try disk cache + var filePath = GetCacheFilePath(key); + if (File.Exists(filePath)) + { + var data = await File.ReadAllBytesAsync(filePath); + await SetCoverAsync(key, data); // Add to memory cache + return data; + } + + return null; + } + + public async Task SetCoverAsync(string key, byte[]? coverData) + { + if (coverData == null || coverData.Length == 0) + return; + + // Add to memory cache + if (_cache.Count >= _maxSize) + { + EvictOldest(); + } + + _cache.AddOrUpdate(key, coverData, (_, _) => coverData); + UpdateAccessOrder(key); + + // Save to disk cache + var filePath = GetCacheFilePath(key); + await File.WriteAllBytesAsync(filePath, coverData); + } + + public async Task ClearAsync() + { + _cache.Clear(); + _accessOrder.Clear(); + + try + { + if (Directory.Exists(_cacheDir)) + { + foreach (var file in Directory.GetFiles(_cacheDir)) + { + File.Delete(file); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[CoverCache] Clear error: {ex.Message}"); + } + + await Task.CompletedTask; + } + + private void UpdateAccessOrder(string key) + { + lock (_accessOrder) + { + if (_accessOrder.Contains(key)) + { + var list = _accessOrder.ToList(); + list.Remove(key); + _accessOrder.Clear(); + foreach (var item in list) + _accessOrder.Enqueue(item); + } + _accessOrder.Enqueue(key); + } + } + + private void EvictOldest() + { + lock (_accessOrder) + { + while (_accessOrder.Count > 0 && _cache.Count >= _maxSize) + { + var oldest = _accessOrder.Dequeue(); + if (_cache.TryRemove(oldest, out var data)) + { + var filePath = GetCacheFilePath(oldest); + if (File.Exists(filePath)) + { + try { File.Delete(filePath); } catch { } + } + } + } + } + } + + private string GetCacheFilePath(string key) + { + // Sanitize key for filename + var sanitized = string.Concat(key.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(_cacheDir, $"{sanitized}.cache"); + } +} diff --git a/BookReader/ViewModels/BookshelfViewModel.cs b/BookReader/ViewModels/BookshelfViewModel.cs index 4a6a3f9..11f25fa 100644 --- a/BookReader/ViewModels/BookshelfViewModel.cs +++ b/BookReader/ViewModels/BookshelfViewModel.cs @@ -12,6 +12,7 @@ public partial class BookshelfViewModel : BaseViewModel private readonly IBookParserService _bookParserService; private readonly ISettingsService _settingsService; private readonly INavigationService _navigationService; + private readonly ICachedImageLoadingService _imageLoadingService; public ObservableCollection Books { get; } = new(); @@ -22,12 +23,14 @@ public partial class BookshelfViewModel : BaseViewModel IDatabaseService databaseService, IBookParserService bookParserService, ISettingsService settingsService, - INavigationService navigationService) + INavigationService navigationService, + ICachedImageLoadingService imageLoadingService) { _databaseService = databaseService; _bookParserService = bookParserService; _settingsService = settingsService; _navigationService = navigationService; + _imageLoadingService = imageLoadingService; Title = "My Library"; } diff --git a/BookReader/ViewModels/CalibreLibraryViewModel.cs b/BookReader/ViewModels/CalibreLibraryViewModel.cs index b56fa27..2e40bcf 100644 --- a/BookReader/ViewModels/CalibreLibraryViewModel.cs +++ b/BookReader/ViewModels/CalibreLibraryViewModel.cs @@ -13,6 +13,7 @@ public partial class CalibreLibraryViewModel : BaseViewModel private readonly IDatabaseService _databaseService; private readonly ISettingsService _settingsService; private readonly INavigationService _navigationService; + private readonly ICachedImageLoadingService _imageLoadingService; public ObservableCollection Books { get; } = new(); @@ -32,13 +33,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel IBookParserService bookParserService, IDatabaseService databaseService, ISettingsService settingsService, - INavigationService navigationService) + INavigationService navigationService, + ICachedImageLoadingService imageLoadingService) { _calibreWebService = calibreWebService; _bookParserService = bookParserService; _databaseService = databaseService; _settingsService = settingsService; _navigationService = navigationService; + _imageLoadingService = imageLoadingService; Title = "Calibre Library"; } diff --git a/BookReader/Views/BookshelfPage.xaml.cs b/BookReader/Views/BookshelfPage.xaml.cs index 7e78a01..1b701e2 100644 --- a/BookReader/Views/BookshelfPage.xaml.cs +++ b/BookReader/Views/BookshelfPage.xaml.cs @@ -8,7 +8,7 @@ public partial class BookshelfPage : ContentPage private readonly BookshelfViewModel _viewModel; private readonly INavigationService _navigationService; - public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService) + public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService, ICachedImageLoadingService imageLoadingService) { InitializeComponent(); _viewModel = viewModel; diff --git a/BookReader/Views/CalibreLibraryPage.xaml.cs b/BookReader/Views/CalibreLibraryPage.xaml.cs index 19a7878..583eacf 100644 --- a/BookReader/Views/CalibreLibraryPage.xaml.cs +++ b/BookReader/Views/CalibreLibraryPage.xaml.cs @@ -1,3 +1,4 @@ +using BookReader.Services; using BookReader.ViewModels; namespace BookReader.Views; @@ -6,7 +7,7 @@ public partial class CalibreLibraryPage : ContentPage { private readonly CalibreLibraryViewModel _viewModel; - public CalibreLibraryPage(CalibreLibraryViewModel viewModel) + public CalibreLibraryPage(CalibreLibraryViewModel viewModel, ICachedImageLoadingService imageLoadingService) { InitializeComponent(); _viewModel = viewModel;