Fixed Delete Series + Issue Covers from Kavita+ (#3784)
This commit is contained in:
parent
3a0d33ca13
commit
bc41b0256e
38 changed files with 2189 additions and 1596 deletions
|
@ -81,6 +81,22 @@ public class CoverDbService : ICoverDbService
|
|||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the favicon image from a given website URL, optionally falling back to a custom method if standard methods fail.
|
||||
/// </summary>
|
||||
/// <param name="url">The full URL of the website to extract the favicon from.</param>
|
||||
/// <param name="encodeFormat">The desired image encoding format for saving the favicon (e.g., WebP, PNG).</param>
|
||||
/// <returns>
|
||||
/// A string representing the filename of the downloaded favicon image, saved to the configured favicon directory.
|
||||
/// </returns>
|
||||
/// <exception cref="KavitaException">
|
||||
/// Thrown when favicon retrieval fails or if a previously failed domain is detected in cache.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// This method first checks for a cached failure to avoid re-requesting bad links.
|
||||
/// It then attempts to parse HTML for `link` tags pointing to `.png` favicons and
|
||||
/// falls back to an internal fallback method if needed. Valid results are saved to disk.
|
||||
/// </remarks>
|
||||
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
||||
{
|
||||
// Parse the URL to get the domain (including subdomain)
|
||||
|
@ -157,23 +173,10 @@ public class CoverDbService : ICoverDbService
|
|||
// Create the destination file path
|
||||
using var image = Image.PngloadStream(faviconStream);
|
||||
var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
image.WriteToFile(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
_logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain);
|
||||
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
@ -212,23 +215,10 @@ public class CoverDbService : ICoverDbService
|
|||
// Create the destination file path
|
||||
using var image = Image.NewFromStream(publisherStream);
|
||||
var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
image.WriteToFile(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize());
|
||||
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
@ -294,40 +284,30 @@ public class CoverDbService : ICoverDbService
|
|||
return null;
|
||||
}
|
||||
|
||||
private async Task<string> DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url)
|
||||
private async Task<string> DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url, string? targetDirectory = null)
|
||||
{
|
||||
// TODO: I need to unit test this to ensure it works when overwriting, etc
|
||||
|
||||
// Target Directory defaults to CoverImageDirectory, but can be temp for when comparison between images is used
|
||||
targetDirectory ??= _directoryService.CoverImageDirectory;
|
||||
|
||||
// Create the destination file path
|
||||
var filename = filenameWithoutExtension + encodeFormat.GetExtension();
|
||||
var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename);
|
||||
|
||||
// Ensure if file exists, we delete to overwrite
|
||||
var targetFile = Path.Combine(targetDirectory, filename);
|
||||
|
||||
_logger.LogTrace("Fetching person image from {Url}", url.Sanitize());
|
||||
// Download the file using Flurl
|
||||
var personStream = await url
|
||||
var imageStream = await url
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
using var image = Image.NewFromStream(personStream);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(targetFile);
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(targetFile);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
using var image = Image.NewFromStream(imageStream);
|
||||
image.WriteToFile(targetFile);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
private async Task<string> GetCoverPersonImagePath(Person person)
|
||||
private async Task<string?> GetCoverPersonImagePath(Person person)
|
||||
{
|
||||
var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml");
|
||||
|
||||
|
@ -384,25 +364,22 @@ public class CoverDbService : ICoverDbService
|
|||
await CacheDataAsync(urlsFileName, allOverrides);
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
if (string.IsNullOrEmpty(allOverrides)) return correctSizeLink;
|
||||
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.FirstOrDefault(url =>
|
||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||
));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.FirstOrDefault(url =>
|
||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||
));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}");
|
||||
}
|
||||
|
||||
correctSizeLink = $"{NewHost}favicons/" + externalFile;
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}");
|
||||
}
|
||||
|
||||
return correctSizeLink;
|
||||
return $"{NewHost}favicons/{externalFile}";
|
||||
}
|
||||
|
||||
private async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
|
@ -415,34 +392,30 @@ public class CoverDbService : ICoverDbService
|
|||
// Cache immediately
|
||||
await CacheDataAsync(publisherFileName, allOverrides);
|
||||
|
||||
if (string.IsNullOrEmpty(allOverrides)) return externalLink;
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
externalLink = $"{NewHost}publishers/" + externalFile;
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
return $"{NewHost}publishers/{externalLink}";
|
||||
}
|
||||
|
||||
private async Task CacheDataAsync(string fileName, string? content)
|
||||
|
@ -485,33 +458,67 @@ public class CoverDbService : ICoverDbService
|
|||
/// <param name="checkNoImagePlaceholder">Will check against all known null image placeholders to avoid writing it</param>
|
||||
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false)
|
||||
{
|
||||
// TODO: Refactor checkNoImagePlaceholder bool to an action that evaluates how to process Image
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64);
|
||||
var tempDir = _directoryService.TempDirectory;
|
||||
var format = ImageService.GetPersonFormat(person.Id);
|
||||
var finalFileName = format + ".webp";
|
||||
var tempFileName = format + "_new";
|
||||
var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir);
|
||||
|
||||
// Additional check to see if downloaded image is similar and we have a higher resolution
|
||||
if (checkNoImagePlaceholder)
|
||||
if (!string.IsNullOrEmpty(tempFilePath))
|
||||
{
|
||||
var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
|
||||
var tempFullPath = Path.Combine(tempDir, tempFilePath);
|
||||
var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName);
|
||||
|
||||
if (matchRating >= 0.9f)
|
||||
// Skip setting image if it's similar to a known placeholder
|
||||
if (checkNoImagePlaceholder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(person.CoverImage))
|
||||
var placeholderPath = Path.Combine(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg");
|
||||
var similarity = placeholderPath.CalculateSimilarity(tempFullPath);
|
||||
if (similarity >= 0.9f)
|
||||
{
|
||||
filePath = null;
|
||||
_logger.LogInformation("Skipped setting placeholder image for person {PersonId} due to high similarity ({Similarity})", person.Id, similarity);
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(person.CoverImage))
|
||||
{
|
||||
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage);
|
||||
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
|
||||
|
||||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (choseNewImage)
|
||||
{
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
person.CoverImage = finalFileName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
person.CoverImage = Path.GetFileName(existingPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage));
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
person.CoverImage = finalFileName;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error choosing better image for Person: {PersonId}", person.Id);
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
person.CoverImage = finalFileName;
|
||||
}
|
||||
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
person.CoverImage = filePath;
|
||||
person.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(person);
|
||||
_unitOfWork.PersonRepository.Update(person);
|
||||
|
@ -544,31 +551,52 @@ public class CoverDbService : ICoverDbService
|
|||
{
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
var filePath = await CreateThumbnail(url, $"{ImageService.GetSeriesFormat(series.Id)}", fromBase64);
|
||||
var tempDir = _directoryService.TempDirectory;
|
||||
var format = ImageService.GetSeriesFormat(series.Id);
|
||||
var finalFileName = format + ".webp";
|
||||
var tempFileName = format + "_new";
|
||||
var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir);
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
if (!string.IsNullOrEmpty(tempFilePath))
|
||||
{
|
||||
// Additional check to see if downloaded image is similar and we have a higher resolution
|
||||
var tempFullPath = Path.Combine(tempDir, tempFilePath);
|
||||
var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName);
|
||||
|
||||
if (chooseBetterImage && !string.IsNullOrEmpty(series.CoverImage))
|
||||
{
|
||||
try
|
||||
{
|
||||
var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage)
|
||||
.GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
|
||||
filePath = Path.GetFileName(betterImage);
|
||||
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, series.CoverImage);
|
||||
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
|
||||
|
||||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (choseNewImage)
|
||||
{
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
series.CoverImage = finalFileName;
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
series.CoverImage = Path.GetFileName(existingPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue trying to choose a better cover image for Series: {SeriesName} ({SeriesId})", series.Name, series.Id);
|
||||
_logger.LogError(ex, "Error choosing better image for Series: {SeriesId}", series.Id);
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
series.CoverImage = finalFileName;
|
||||
}
|
||||
}
|
||||
|
||||
series.CoverImage = filePath;
|
||||
series.CoverImageLocked = true;
|
||||
if (series.CoverImage == null)
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
series.CoverImage = finalFileName;
|
||||
}
|
||||
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
series.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(series);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
@ -577,10 +605,7 @@ public class CoverDbService : ICoverDbService
|
|||
{
|
||||
series.CoverImage = null;
|
||||
series.CoverImageLocked = false;
|
||||
if (series.CoverImage == null)
|
||||
{
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
|
||||
}
|
||||
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
|
||||
_imageService.UpdateColorScape(series);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
@ -597,26 +622,52 @@ public class CoverDbService : ICoverDbService
|
|||
{
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
var filePath = await CreateThumbnail(url, $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}", fromBase64);
|
||||
var tempDirectory = _directoryService.TempDirectory;
|
||||
var finalFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + ".webp";
|
||||
var tempFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + "_new";
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDirectory);
|
||||
|
||||
if (!string.IsNullOrEmpty(tempFilePath))
|
||||
{
|
||||
// Additional check to see if downloaded image is similar and we have a higher resolution
|
||||
var tempFullPath = Path.Combine(tempDirectory, tempFilePath);
|
||||
var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName);
|
||||
|
||||
if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage))
|
||||
{
|
||||
try
|
||||
{
|
||||
var betterImage = Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage)
|
||||
.GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
|
||||
filePath = Path.GetFileName(betterImage);
|
||||
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, chapter.CoverImage);
|
||||
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
|
||||
var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (choseNewImage)
|
||||
{
|
||||
// This will fail if Cover gen is done just before this as there is a bug with files getting locked.
|
||||
_directoryService.DeleteFiles([existingPath]);
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
}
|
||||
|
||||
chapter.CoverImage = finalFileName;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No comparison needed, just copy and rename to final
|
||||
_directoryService.CopyFile(tempFullPath, finalFullPath);
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
chapter.CoverImage = finalFileName;
|
||||
}
|
||||
|
||||
chapter.CoverImage = filePath;
|
||||
chapter.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(chapter);
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
|
@ -633,13 +684,26 @@ public class CoverDbService : ICoverDbService
|
|||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false);
|
||||
await _eventHub.SendMessageAsync(
|
||||
MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="url"></param>
|
||||
/// <param name="filenameWithoutExtension">Filename without extension</param>
|
||||
/// <param name="fromBase64"></param>
|
||||
/// <param name="targetDirectory">Not useable with fromBase64. Allows a different directory to be written to</param>
|
||||
/// <returns></returns>
|
||||
private async Task<string> CreateThumbnail(string url, string filenameWithoutExtension, bool fromBase64 = true, string? targetDirectory = null)
|
||||
{
|
||||
targetDirectory ??= _directoryService.CoverImageDirectory;
|
||||
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var encodeFormat = settings.EncodeMediaAs;
|
||||
var coverImageSize = settings.CoverImageSize;
|
||||
|
@ -647,9 +711,9 @@ public class CoverDbService : ICoverDbService
|
|||
if (fromBase64)
|
||||
{
|
||||
return _imageService.CreateThumbnailFromBase64(url,
|
||||
filename, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||
filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||
}
|
||||
|
||||
return await DownloadImageFromUrl(filename, encodeFormat, url);
|
||||
return await DownloadImageFromUrl(filenameWithoutExtension, encodeFormat, url, targetDirectory);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue