qwen edit
This commit is contained in:
@@ -31,6 +31,8 @@ public static class MauiProgram
|
|||||||
builder.Services.AddSingleton<IBookParserService, BookParserService>();
|
builder.Services.AddSingleton<IBookParserService, BookParserService>();
|
||||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddSingleton<INavigationService, NavigationService>();
|
builder.Services.AddSingleton<INavigationService, NavigationService>();
|
||||||
|
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
|
||||||
|
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
|
||||||
|
|
||||||
// HTTP Client for Calibre
|
// HTTP Client for Calibre
|
||||||
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
|
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
|
public string? CalibreId { get; set; } // ID from Calibre-Web if downloaded from there
|
||||||
|
|
||||||
|
[Ignore]
|
||||||
|
public string? CoverCacheKey => $"book_{Id}_cover";
|
||||||
|
|
||||||
[Ignore]
|
[Ignore]
|
||||||
public ImageSource? CoverImageSource
|
public ImageSource? CoverImageSource
|
||||||
{
|
{
|
||||||
@@ -45,7 +48,7 @@ public class Book
|
|||||||
{
|
{
|
||||||
return ImageSource.FromStream(() => new MemoryStream(CoverImage));
|
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
|
public class CalibreBook
|
||||||
{
|
{
|
||||||
@@ -10,6 +12,10 @@ public class CalibreBook
|
|||||||
public string Format { get; set; } = string.Empty;
|
public string Format { get; set; } = string.Empty;
|
||||||
public byte[]? CoverImage { get; set; }
|
public byte[]? CoverImage { get; set; }
|
||||||
|
|
||||||
|
[Ignore]
|
||||||
|
public string CoverCacheKey => $"calibre_{Id}_cover";
|
||||||
|
|
||||||
|
[Ignore]
|
||||||
public ImageSource? CoverImageSource
|
public ImageSource? CoverImageSource
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -27,13 +27,21 @@ public class BookParserService : IBookParserService
|
|||||||
var bookId = Guid.NewGuid().ToString();
|
var bookId = Guid.NewGuid().ToString();
|
||||||
var destPath = Path.Combine(_booksDir, $"{bookId}{extension}");
|
var destPath = Path.Combine(_booksDir, $"{bookId}{extension}");
|
||||||
|
|
||||||
// Copy file to app storage
|
// Перемещаем файл вместо копирования для экономии памяти
|
||||||
if (sourceFilePath != destPath)
|
if (sourceFilePath != destPath)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(sourceFilePath, destPath, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Если перемещение не удалось (например, файл на внешнем носителе), копируем
|
||||||
using var sourceStream = File.OpenRead(sourceFilePath);
|
using var sourceStream = File.OpenRead(sourceFilePath);
|
||||||
using var destStream = File.Create(destPath);
|
using var destStream = File.Create(destPath);
|
||||||
await sourceStream.CopyToAsync(destStream);
|
await sourceStream.CopyToAsync(destStream);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var book = new Book
|
var book = new Book
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ namespace BookReader.Services;
|
|||||||
public class CalibreWebService : ICalibreWebService
|
public class CalibreWebService : ICalibreWebService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ICoverCacheService _coverCacheService;
|
||||||
private string _baseUrl = string.Empty;
|
private string _baseUrl = string.Empty;
|
||||||
private string _username = string.Empty;
|
private string _username = string.Empty;
|
||||||
private string _password = string.Empty;
|
private string _password = string.Empty;
|
||||||
|
|
||||||
public CalibreWebService(HttpClient httpClient)
|
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
|
_coverCacheService = coverCacheService;
|
||||||
_httpClient.Timeout = TimeSpan.FromSeconds(Constants.Network.HttpClientTimeoutSeconds);
|
_httpClient.Timeout = TimeSpan.FromSeconds(Constants.Network.HttpClientTimeoutSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +63,33 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
|
|
||||||
var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>();
|
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
|
||||||
|
{
|
||||||
|
return await LoadBookDataAsync(bookId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
books.AddRange(results.Where(b => b != null)!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return books;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CalibreBook?> LoadBookDataAsync(int bookId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -74,7 +102,7 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
|
f.Equals("EPUB", StringComparison.OrdinalIgnoreCase) ||
|
||||||
f.Equals("FB2", StringComparison.OrdinalIgnoreCase));
|
f.Equals("FB2", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (supportedFormat == null) continue;
|
if (supportedFormat == null) return null;
|
||||||
|
|
||||||
var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>();
|
var authors = bookJson["authors"]?.ToObject<List<string>>() ?? new List<string>();
|
||||||
|
|
||||||
@@ -88,24 +116,26 @@ public class CalibreWebService : ICalibreWebService
|
|||||||
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
|
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to load cover
|
// Try to load cover from cache first
|
||||||
|
var cacheKey = $"cover_{bookId}";
|
||||||
|
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
|
||||||
|
|
||||||
|
if (calibreBook.CoverImage == null)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
|
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
|
||||||
|
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
books.Add(calibreBook);
|
return calibreBook;
|
||||||
}
|
}
|
||||||
catch { continue; }
|
catch
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
{
|
||||||
System.Diagnostics.Debug.WriteLine($"Error fetching Calibre books: {ex.Message}");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return books;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
|
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
|
||||||
|
|||||||
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 IBookParserService _bookParserService;
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly INavigationService _navigationService;
|
private readonly INavigationService _navigationService;
|
||||||
|
private readonly ICachedImageLoadingService _imageLoadingService;
|
||||||
|
|
||||||
public ObservableCollection<Book> Books { get; } = new();
|
public ObservableCollection<Book> Books { get; } = new();
|
||||||
|
|
||||||
@@ -22,12 +23,14 @@ public partial class BookshelfViewModel : BaseViewModel
|
|||||||
IDatabaseService databaseService,
|
IDatabaseService databaseService,
|
||||||
IBookParserService bookParserService,
|
IBookParserService bookParserService,
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
INavigationService navigationService)
|
INavigationService navigationService,
|
||||||
|
ICachedImageLoadingService imageLoadingService)
|
||||||
{
|
{
|
||||||
_databaseService = databaseService;
|
_databaseService = databaseService;
|
||||||
_bookParserService = bookParserService;
|
_bookParserService = bookParserService;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_navigationService = navigationService;
|
_navigationService = navigationService;
|
||||||
|
_imageLoadingService = imageLoadingService;
|
||||||
Title = "My Library";
|
Title = "My Library";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
|||||||
private readonly IDatabaseService _databaseService;
|
private readonly IDatabaseService _databaseService;
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly INavigationService _navigationService;
|
private readonly INavigationService _navigationService;
|
||||||
|
private readonly ICachedImageLoadingService _imageLoadingService;
|
||||||
|
|
||||||
public ObservableCollection<CalibreBook> Books { get; } = new();
|
public ObservableCollection<CalibreBook> Books { get; } = new();
|
||||||
|
|
||||||
@@ -32,13 +33,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
|||||||
IBookParserService bookParserService,
|
IBookParserService bookParserService,
|
||||||
IDatabaseService databaseService,
|
IDatabaseService databaseService,
|
||||||
ISettingsService settingsService,
|
ISettingsService settingsService,
|
||||||
INavigationService navigationService)
|
INavigationService navigationService,
|
||||||
|
ICachedImageLoadingService imageLoadingService)
|
||||||
{
|
{
|
||||||
_calibreWebService = calibreWebService;
|
_calibreWebService = calibreWebService;
|
||||||
_bookParserService = bookParserService;
|
_bookParserService = bookParserService;
|
||||||
_databaseService = databaseService;
|
_databaseService = databaseService;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_navigationService = navigationService;
|
_navigationService = navigationService;
|
||||||
|
_imageLoadingService = imageLoadingService;
|
||||||
Title = "Calibre Library";
|
Title = "Calibre Library";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public partial class BookshelfPage : ContentPage
|
|||||||
private readonly BookshelfViewModel _viewModel;
|
private readonly BookshelfViewModel _viewModel;
|
||||||
private readonly INavigationService _navigationService;
|
private readonly INavigationService _navigationService;
|
||||||
|
|
||||||
public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService)
|
public BookshelfPage(BookshelfViewModel viewModel, INavigationService navigationService, ICachedImageLoadingService imageLoadingService)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using BookReader.Services;
|
||||||
using BookReader.ViewModels;
|
using BookReader.ViewModels;
|
||||||
|
|
||||||
namespace BookReader.Views;
|
namespace BookReader.Views;
|
||||||
@@ -6,7 +7,7 @@ public partial class CalibreLibraryPage : ContentPage
|
|||||||
{
|
{
|
||||||
private readonly CalibreLibraryViewModel _viewModel;
|
private readonly CalibreLibraryViewModel _viewModel;
|
||||||
|
|
||||||
public CalibreLibraryPage(CalibreLibraryViewModel viewModel)
|
public CalibreLibraryPage(CalibreLibraryViewModel viewModel, ICachedImageLoadingService imageLoadingService)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
|||||||
Reference in New Issue
Block a user