qwen edit

This commit is contained in:
Курнат Андрей
2026-02-18 14:36:57 +03:00
parent 8cb459c832
commit 1d9224a025
13 changed files with 292 additions and 45 deletions

View File

@@ -31,6 +31,8 @@ public static class MauiProgram
builder.Services.AddSingleton<IBookParserService, BookParserService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton<INavigationService, NavigationService>();
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
// HTTP Client for Calibre
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<List<int>>() ?? new List<int>();
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<List<string>>() ?? new List<string>();
var supportedFormat = formats.FirstOrDefault(f =>
f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
f.Equals("FB2", StringComparison.OrdinalIgnoreCase));
if (supportedFormat == null) continue;
var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>();
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<CalibreBook?> 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<List<string>>() ?? new List<string>();
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<List<string>>() ?? new List<string>();
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<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
{
var booksDir = Path.Combine(FileSystem.AppDataDirectory, "Books");

View File

@@ -0,0 +1,8 @@
namespace BookReader.Services;
public interface ICoverCacheService
{
Task<byte[]?> GetCoverAsync(string key);
Task SetCoverAsync(string key, byte[]? coverData);
Task ClearAsync();
}

View File

@@ -0,0 +1,10 @@
namespace BookReader.Services;
/// <summary>
/// Сервис для оптимизированной загрузки изображений с кэшированием
/// </summary>
public interface ICachedImageLoadingService
{
Task<ImageSource?> LoadImageAsync(byte[]? imageData, string cacheKey);
void ClearCache();
}

View File

@@ -0,0 +1,50 @@
using System.Collections.Concurrent;
namespace BookReader.Services;
/// <summary>
/// Сервис для оптимизированной загрузки изображений с кэшированием ImageSource
/// </summary>
public class CachedImageLoadingService : ICachedImageLoadingService
{
private readonly ConcurrentDictionary<string, ImageSource> _memoryCache = new();
private readonly SemaphoreSlim _semaphore = new(3); // Максимум 3 параллельных загрузки
public async Task<ImageSource?> 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();
}
}

View File

@@ -0,0 +1,123 @@
using System.Collections.Concurrent;
namespace BookReader.Services;
public class LruCoverCacheService : ICoverCacheService
{
private readonly ConcurrentDictionary<string, byte[]> _cache = new();
private readonly Queue<string> _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<byte[]?> 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");
}
}

View File

@@ -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<Book> 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";
}

View File

@@ -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<CalibreBook> 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";
}

View File

@@ -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;

View File

@@ -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;