qwen edit
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
8
BookReader/Services/ICoverCacheService.cs
Normal file
8
BookReader/Services/ICoverCacheService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace BookReader.Services;
|
||||
|
||||
public interface ICoverCacheService
|
||||
{
|
||||
Task<byte[]?> GetCoverAsync(string key);
|
||||
Task SetCoverAsync(string key, byte[]? coverData);
|
||||
Task ClearAsync();
|
||||
}
|
||||
10
BookReader/Services/IImageLoadingService.cs
Normal file
10
BookReader/Services/IImageLoadingService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace BookReader.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Сервис для оптимизированной загрузки изображений с кэшированием
|
||||
/// </summary>
|
||||
public interface ICachedImageLoadingService
|
||||
{
|
||||
Task<ImageSource?> LoadImageAsync(byte[]? imageData, string cacheKey);
|
||||
void ClearCache();
|
||||
}
|
||||
50
BookReader/Services/ImageLoadingService.cs
Normal file
50
BookReader/Services/ImageLoadingService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
123
BookReader/Services/LruCoverCacheService.cs
Normal file
123
BookReader/Services/LruCoverCacheService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user