diff --git a/Source/LibationFileManager/IInteropFunctions.cs b/Source/LibationFileManager/IInteropFunctions.cs index 897ce186..edbee7c1 100644 --- a/Source/LibationFileManager/IInteropFunctions.cs +++ b/Source/LibationFileManager/IInteropFunctions.cs @@ -5,6 +5,7 @@ namespace LibationFileManager; public interface IInteropFunctions { void SetFolderIcon(string image, string directory); + void SetFolderIcon(byte[] imageJpegBytes, string directory); void DeleteFolderIcon(string directory); Process? RunAsRoot(string exe, string args); void InstallUpgrade(string upgradeBundle); diff --git a/Source/LibationFileManager/NullInteropFunctions.cs b/Source/LibationFileManager/NullInteropFunctions.cs index f184a471..3cd3f078 100644 --- a/Source/LibationFileManager/NullInteropFunctions.cs +++ b/Source/LibationFileManager/NullInteropFunctions.cs @@ -10,6 +10,7 @@ public class NullInteropFunctions : IInteropFunctions public NullInteropFunctions(params object[] values) { } public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); + public void SetFolderIcon(byte[] imageJpegBytes, string directory) => throw new PlatformNotSupportedException(); public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); public bool CanUpgrade => throw new PlatformNotSupportedException(); public string ReleaseIdString => throw new PlatformNotSupportedException(); diff --git a/Source/LibationFileManager/PictureStorage.cs b/Source/LibationFileManager/PictureStorage.cs index 67b49b6f..b95ac637 100644 --- a/Source/LibationFileManager/PictureStorage.cs +++ b/Source/LibationFileManager/PictureStorage.cs @@ -88,25 +88,30 @@ public static class PictureStorage { lock (cacheLocker) { - if (!cache.ContainsKey(def) || cache[def] is null) + var path = getPath(def); + + // Disk is authoritative. Ignore in-memory cache when the file is missing so a later + // successful download (or a CDN that omits Content-Length) is not blocked by a stale placeholder. + if (File.Exists(path)) { - var path = getPath(def); - var bytes - = File.Exists(path) - ? File.ReadAllBytes(path) - : downloadBytes(def, cancellationToken); - cache[def] = bytes; + cache[def] = File.ReadAllBytes(path); + return cache[def]; } - return cache[def]; + + if (cache.ContainsKey(def)) + cache.Remove(def); + + var bytes = downloadBytes(def, cancellationToken); + if (File.Exists(path)) + cache[def] = bytes; + return bytes; } } public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes) => defaultImages[pictureSize] = bytes; public static byte[] GetDefaultImage(PictureSize size) - => defaultImages.ContainsKey(size) - ? defaultImages[size] - : new byte[0]; + => defaultImages.TryGetValue(size, out byte[]? value) ? value : []; static void BackgroundDownloader() { @@ -124,6 +129,9 @@ public static class PictureStorage } private static HttpClient imageDownloadClient { get; } = new HttpClient(); + + private const long MaxPictureDownloadBytes = 25 * 1024 * 1024; + private static byte[] downloadBytes(PictureDefinition def, CancellationToken cancellationToken = default) { if (def.PictureId is null) @@ -136,14 +144,36 @@ public static class PictureStorage using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg"); using var response = imageDownloadClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).EnsureSuccessStatusCode(); - if (response.Content.Headers.ContentLength is not long size) - return GetDefaultImage(def.Size); + byte[] bytes; + if (response.Content.Headers.ContentLength is long knownSize && knownSize >= 0) + { + if (knownSize == 0 || knownSize > MaxPictureDownloadBytes) + return GetDefaultImage(def.Size); - var bytes = new byte[size]; - using var respStream = response.Content.ReadAsStream(cancellationToken); - respStream.ReadExactly(bytes); + bytes = new byte[knownSize]; + using (var respStream = response.Content.ReadAsStream(cancellationToken)) + respStream.ReadExactly(bytes); + } + else + { + // Chunked responses often omit Content-Length; the previous implementation treated that as failure, + // left no file on disk, and callers that opened the expected path then broke (e.g. folder icons). + using var respStream = response.Content.ReadAsStream(cancellationToken); + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + int read; + while ((read = respStream.Read(buffer, 0, buffer.Length)) > 0) + { + if (ms.Length + read > MaxPictureDownloadBytes) + return GetDefaultImage(def.Size); + ms.Write(buffer, 0, read); + } + + bytes = ms.ToArray(); + if (bytes.Length == 0) + return GetDefaultImage(def.Size); + } - // save image file. make sure to not save default image var path = getPath(def); File.WriteAllBytes(path, bytes); diff --git a/Source/LibationFileManager/WindowsDirectory.cs b/Source/LibationFileManager/WindowsDirectory.cs index c55c322f..d507cedd 100644 --- a/Source/LibationFileManager/WindowsDirectory.cs +++ b/Source/LibationFileManager/WindowsDirectory.cs @@ -18,9 +18,16 @@ public static class WindowsDirectory return; } - // get path of cover art in Images dir. Download first if not exists - var coverArtPath = PictureStorage.GetPicturePathSynchronously(new(pictureId, PictureSize._300x300), cancellationToken); - InteropFactory.Create().SetFolderIcon(image: coverArtPath, directory: directory); + // Load JPEG bytes from Images cache (or download). Prefer bytes → ICO so we never depend on a + // path that might not exist when Amazon omits Content-Length or another downloader left a stale cache entry. + var jpegBytes = PictureStorage.GetPictureSynchronously(new(pictureId, PictureSize._300x300), cancellationToken); + if (jpegBytes.Length == 0) + { + Serilog.Log.Logger.Warning("Cover art unavailable for folder icon (empty image). {@DebugInfo}", new { directory, pictureId }); + return; + } + + InteropFactory.Create().SetFolderIcon(imageJpegBytes: jpegBytes, directory: directory); } catch (Exception ex) { diff --git a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs index 0cbc186f..83a1ed56 100644 --- a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs +++ b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs @@ -22,6 +22,7 @@ internal class LinuxInterop : IInteropFunctions public LinuxInterop(params object[] values) { } public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); + public void SetFolderIcon(byte[] imageJpegBytes, string directory) => throw new PlatformNotSupportedException(); public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); public string ReleaseIdString => LibationScaffolding.ReleaseIdentifier.ToString() + (File.Exists("/bin/apt") ? "_DEB" : "_RPM"); diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs index f205721d..13bd5c00 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs +++ b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs @@ -1,6 +1,7 @@ using Dinah.Core; using LibationFileManager; using System.Diagnostics; +using System.IO; namespace MacOSConfigApp; @@ -14,6 +15,25 @@ internal class MacOSInterop : IInteropFunctions { Process.Start("fileicon", $"set {directory.SurroundWithQuotes()} {image.SurroundWithQuotes()}").WaitForExit(); } + + public void SetFolderIcon(byte[] imageJpegBytes, string directory) + { + var tempPath = Path.Combine(Path.GetTempPath(), $"LibationFolderIcon-{Guid.NewGuid():N}.jpg"); + try + { + File.WriteAllBytes(tempPath, imageJpegBytes); + SetFolderIcon(tempPath, directory); + } + finally + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch { /* best effort */ } + } + } public void DeleteFolderIcon(string directory) { Process.Start("fileicon", $"rm {directory.SurroundWithQuotes()}").WaitForExit(); diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index 822f1463..cc97654a 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -13,7 +13,15 @@ internal class WinInterop : IInteropFunctions public WinInterop(params object[] values) { } public void SetFolderIcon(string image, string directory) { - var icon = Image.Load(image).ToIcon(); + using var img = Image.Load(image); + var icon = img.ToIcon(); + new DirectoryInfo(directory)?.SetIcon(icon, "Music"); + } + + public void SetFolderIcon(byte[] imageJpegBytes, string directory) + { + using var img = Image.Load(new MemoryStream(imageJpegBytes, writable: false)); + var icon = img.ToIcon(); new DirectoryInfo(directory)?.SetIcon(icon, "Music"); }