From 91e033a2ec447e14e2c16f3e63a71fa30d429db6 Mon Sep 17 00:00:00 2001 From: Glax Date: Mon, 31 Mar 2025 19:08:35 +0200 Subject: [PATCH] Logging --- API/Program.cs | 4 +- API/Schema/Jobs/DownloadMangaCoverJob.cs | 4 + API/Schema/Jobs/DownloadSingleChapterJob.cs | 66 ++++++++-- API/Schema/Jobs/Job.cs | 33 +++-- API/Schema/Jobs/MoveFileOrFolderJob.cs | 11 +- API/Schema/Jobs/MoveMangaLibraryJob.cs | 25 +++- API/Schema/Jobs/RetrieveChaptersJob.cs | 43 +++++-- API/Schema/Jobs/UpdateMetadataJob.cs | 1 + API/Schema/MangaConnectors/AsuraToon.cs | 1 + API/Schema/MangaConnectors/MangaConnector.cs | 5 + .../NotificationConnector.cs | 10 +- API/Tranga.cs | 121 +++++++++++++----- 12 files changed, 247 insertions(+), 77 deletions(-) diff --git a/API/Program.cs b/API/Program.cs index 02fcb1b..56d48b2 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -95,7 +95,7 @@ app.UseSwaggerUI(options => app.UseHttpsRedirection(); -//app.UseMiddleware(); +app.UseMiddleware(); using (var scope = app.Services.CreateScope()) { @@ -140,7 +140,7 @@ using (var scope = app.Services.CreateScope()) TrangaSettings.Load(); Tranga.StartLogger(); Tranga.JobStarterThread.Start(app.Services); -Tranga.NotificationSenderThread.Start(app.Services.CreateScope().ServiceProvider.GetService()); +Tranga.NotificationSenderThread.Start(app.Services); app.UseCors("AllowAll"); diff --git a/API/Schema/Jobs/DownloadMangaCoverJob.cs b/API/Schema/Jobs/DownloadMangaCoverJob.cs index 485ffc6..7446fa8 100644 --- a/API/Schema/Jobs/DownloadMangaCoverJob.cs +++ b/API/Schema/Jobs/DownloadMangaCoverJob.cs @@ -16,10 +16,14 @@ public class DownloadMangaCoverJob(string mangaId, string? parentJobId = null, I { Manga? manga = Manga ?? context.Mangas.Find(this.MangaId); if (manga is null) + { + Log.Error($"Manga {this.MangaId} not found."); return []; + } manga.CoverFileNameInCache = manga.SaveCoverImageToCache(); context.SaveChanges(); + Log.Info($"Saved cover for Manga {this.MangaId} to cache at {manga.CoverFileNameInCache}."); return []; } } \ No newline at end of file diff --git a/API/Schema/Jobs/DownloadSingleChapterJob.cs b/API/Schema/Jobs/DownloadSingleChapterJob.cs index c623e49..51259c0 100644 --- a/API/Schema/Jobs/DownloadSingleChapterJob.cs +++ b/API/Schema/Jobs/DownloadSingleChapterJob.cs @@ -24,48 +24,80 @@ public class DownloadSingleChapterJob(string chapterId, string? parentJobId = nu protected override IEnumerable RunInternal(PgsqlContext context) { - Chapter chapter = Chapter ?? context.Chapters.Find(ChapterId)!; - Manga manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId)!; - MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!; + Chapter? chapter = Chapter ?? context.Chapters.Find(ChapterId); + if (chapter is null) + { + Log.Error("Chapter is null."); + return []; + } + Manga? manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId); + if (manga is null) + { + Log.Error("Manga is null."); + return []; + } + MangaConnector? connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId); + if (connector is null) + { + Log.Error("Connector is null."); + return []; + } string[] imageUrls = connector.GetChapterImageUrls(chapter); - string saveArchiveFilePath = chapter.FullArchiveFilePath; + if (imageUrls.Length < 1) + { + Log.Info($"No imageUrls for chapter {chapterId}"); + return []; + } + string? saveArchiveFilePath = chapter.FullArchiveFilePath; + if (saveArchiveFilePath is null) + { + Log.Error("saveArchiveFilePath is null."); + return []; + } //Check if Publication Directory already exists string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!; if (!Directory.Exists(directoryPath)) + { + Log.Info($"Creating publication Directory: {directoryPath}"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) Directory.CreateDirectory(directoryPath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute ); else Directory.CreateDirectory(directoryPath); + } if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload + { + Log.Info($"Archive {saveArchiveFilePath} already existed, but deleting and re-downloading."); File.Delete(saveArchiveFilePath); + } //Create a temporary folder to store images string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName; + Log.Debug($"Created temp folder: {tempFolder}"); + Log.Info($"Downloading images: {ChapterId}"); int chapterNum = 0; //Download all Images to temporary Folder - if (imageUrls.Length == 0) - { - Directory.Delete(tempFolder, true); - return []; - } - foreach (string imageUrl in imageUrls) { string extension = imageUrl.Split('.')[^1].Split('?')[0]; string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}"); bool status = DownloadImage(imageUrl, imagePath); if (status is false) + { + Log.Error($"Failed to download image: {imageUrl}"); return []; + } } - + CopyCoverFromCacheToDownloadLocation(manga); + Log.Debug($"Creating ComicInfo.xml {ChapterId}"); File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString()); + Log.Debug($"Packaging images to archive {ChapterId}"); //ZIP-it and ship-it ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath); if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) @@ -81,7 +113,13 @@ public class DownloadSingleChapterJob(string chapterId, string? parentJobId = nu private void ProcessImage(string imagePath) { if (!TrangaSettings.bwImages && TrangaSettings.compression == 100) + { + Log.Debug($"No processing requested for image"); return; + } + + Log.Debug($"Processing image: {imagePath}"); + using Image image = Image.Load(imagePath); File.Delete(imagePath); if(TrangaSettings.bwImages) @@ -99,17 +137,23 @@ public class DownloadSingleChapterJob(string chapterId, string? parentJobId = nu DirectoryInfo dirInfo = new (publicationFolder); if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase))) { + Log.Debug($"Cover already exists at {publicationFolder}"); return; } + Log.Info($"Copying cover to {publicationFolder}"); string? fileInCache = manga.CoverFileNameInCache ?? manga.SaveCoverImageToCache(); if (fileInCache is null) + { + Log.Error($"File {fileInCache} does not exist"); return; + } string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" ); File.Copy(fileInCache, newFilePath, true); if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite); + Log.Debug($"Copied cover from {fileInCache} to {newFilePath}"); } private bool DownloadImage(string imageUrl, string savePath) diff --git a/API/Schema/Jobs/Job.cs b/API/Schema/Jobs/Job.cs index cf22541..0c35ace 100644 --- a/API/Schema/Jobs/Job.cs +++ b/API/Schema/Jobs/Job.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using log4net; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; @@ -34,6 +35,10 @@ public abstract class Job public JobState state { get; internal set; } = JobState.Waiting; [Required] public bool Enabled { get; internal set; } = true; + + [NotMapped] + [JsonIgnore] + protected ILog Log { get; init; } public Job(string jobId, JobType jobType, ulong recurrenceMs, Job? parentJob = null, ICollection? dependsOnJobs = null) : this(jobId, jobType, recurrenceMs, parentJob?.JobId, dependsOnJobs?.Select(j => j.JobId).ToList()) @@ -44,6 +49,7 @@ public abstract class Job public Job(string jobId, JobType jobType, ulong recurrenceMs, string? parentJobId = null, ICollection? dependsOnJobsIds = null) { + Log = LogManager.GetLogger(GetType()); JobId = jobId; ParentJobId = parentJobId; DependsOnJobsIds = dependsOnJobsIds; @@ -53,16 +59,27 @@ public abstract class Job public IEnumerable Run(IServiceProvider serviceProvider) { + Log.Debug($"Running job {JobId}"); using IServiceScope scope = serviceProvider.CreateScope(); PgsqlContext context = scope.ServiceProvider.GetRequiredService(); - - this.state = JobState.Running; - context.SaveChanges(); - Job[] newJobs = RunInternal(context).ToArray(); - this.state = JobState.Completed; - context.Jobs.AddRange(newJobs); - context.SaveChanges(); - return newJobs; + + try + { + this.state = JobState.Running; + context.SaveChanges(); + Job[] newJobs = RunInternal(context).ToArray(); + this.state = JobState.Completed; + context.Jobs.AddRange(newJobs); + context.SaveChanges(); + Log.Info($"Job {JobId} completed. Generated {newJobs.Length} new jobs."); + return newJobs; + } + catch (DbUpdateException e) + { + this.state = JobState.Failed; + Log.Error($"Failed to run job {JobId}", e); + return []; + } } protected abstract IEnumerable RunInternal(PgsqlContext context); diff --git a/API/Schema/Jobs/MoveFileOrFolderJob.cs b/API/Schema/Jobs/MoveFileOrFolderJob.cs index 0eb805b..0857ea8 100644 --- a/API/Schema/Jobs/MoveFileOrFolderJob.cs +++ b/API/Schema/Jobs/MoveFileOrFolderJob.cs @@ -16,11 +16,18 @@ public class MoveFileOrFolderJob(string fromLocation, string toLocation, string? { try { - FileInfo fi = new FileInfo(FromLocation); + FileInfo fi = new (FromLocation); if (!fi.Exists) + { + Log.Error($"File does not exist at {FromLocation}"); return []; + } + if (File.Exists(ToLocation))//Do not override existing + { + Log.Error($"File already exists at {ToLocation}"); return []; + } if(fi.Attributes.HasFlag(FileAttributes.Directory)) MoveDirectory(fi, ToLocation); else @@ -28,7 +35,7 @@ public class MoveFileOrFolderJob(string fromLocation, string toLocation, string? } catch (Exception e) { - + Log.Error(e); } return []; diff --git a/API/Schema/Jobs/MoveMangaLibraryJob.cs b/API/Schema/Jobs/MoveMangaLibraryJob.cs index 8159820..210bb28 100644 --- a/API/Schema/Jobs/MoveMangaLibraryJob.cs +++ b/API/Schema/Jobs/MoveMangaLibraryJob.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; namespace API.Schema.Jobs; @@ -15,15 +16,29 @@ public class MoveMangaLibraryJob(string mangaId, string toLibraryId, string? par protected override IEnumerable RunInternal(PgsqlContext context) { Manga? manga = context.Mangas.Find(MangaId); - if(manga is null) - throw new KeyNotFoundException(); + if (manga is null) + { + Log.Error("Manga not found"); + return []; + } LocalLibrary? library = context.LocalLibraries.Find(ToLibraryId); - if(library is null) - throw new KeyNotFoundException(); + if (library is null) + { + Log.Error("LocalLibrary not found"); + return []; + } Chapter[] chapters = context.Chapters.Where(c => c.ParentMangaId == MangaId).ToArray(); Dictionary oldPath = chapters.ToDictionary(c => c, c => c.FullArchiveFilePath!); manga.Library = library; - context.SaveChanges(); + try + { + context.SaveChanges(); + } + catch (DbUpdateException e) + { + Log.Error(e); + return []; + } return chapters.Select(c => new MoveFileOrFolderJob(oldPath[c], c.FullArchiveFilePath!)); } diff --git a/API/Schema/Jobs/RetrieveChaptersJob.cs b/API/Schema/Jobs/RetrieveChaptersJob.cs index c485226..e49ad44 100644 --- a/API/Schema/Jobs/RetrieveChaptersJob.cs +++ b/API/Schema/Jobs/RetrieveChaptersJob.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using API.Schema.MangaConnectors; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; namespace API.Schema.Jobs; @@ -16,21 +17,35 @@ public class RetrieveChaptersJob(ulong recurrenceMs, string mangaId, string? par protected override IEnumerable RunInternal(PgsqlContext context) { - /* - * For some reason, directly using Manga from above instead of finding it again causes DBContext to consider - * Manga as a new entity and Postgres throws a Duplicate PK exception. - * m.MangaConnector does not have this issue (IDK why). - */ - Manga m = context.Mangas.Find(MangaId)!; - MangaConnector connector = context.MangaConnectors.Find(m.MangaConnectorId)!; + Manga? manga = Manga ?? context.Mangas.Find(MangaId); + if (manga is null) + { + Log.Error("Manga is null."); + return []; + } + MangaConnector? connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId); + if (connector is null) + { + Log.Error("Connector is null."); + return []; + } // This gets all chapters that are not downloaded - Chapter[] allNewChapters = connector.GetNewChapters(m).DistinctBy(c => c.ChapterId).ToArray(); - - // This filters out chapters that are not downloaded but already exist in the DB - string[] chapterIds = context.Chapters.Where(chapter => chapter.ParentMangaId == m.MangaId).Select(chapter => chapter.ChapterId).ToArray(); - Chapter[] newChapters = allNewChapters.Where(chapter => !chapterIds.Contains(chapter.ChapterId)).ToArray(); - context.Chapters.AddRange(newChapters); - context.SaveChanges(); + Chapter[] allNewChapters = connector.GetNewChapters(manga).DistinctBy(c => c.ChapterId).ToArray(); + Log.Info($"{allNewChapters.Length} new chapters."); + + try + { + // This filters out chapters that are not downloaded but already exist in the DB + string[] chapterIds = context.Chapters.Where(chapter => chapter.ParentMangaId == manga.MangaId) + .Select(chapter => chapter.ChapterId).ToArray(); + Chapter[] newChapters = allNewChapters.Where(chapter => !chapterIds.Contains(chapter.ChapterId)).ToArray(); + context.Chapters.AddRange(newChapters); + context.SaveChanges(); + } + catch (DbUpdateException e) + { + Log.Error(e); + } return []; } diff --git a/API/Schema/Jobs/UpdateMetadataJob.cs b/API/Schema/Jobs/UpdateMetadataJob.cs index 14a2164..6fe238e 100644 --- a/API/Schema/Jobs/UpdateMetadataJob.cs +++ b/API/Schema/Jobs/UpdateMetadataJob.cs @@ -21,6 +21,7 @@ public class UpdateMetadataJob(ulong recurrenceMs, string mangaId, string? paren /// protected override IEnumerable RunInternal(PgsqlContext context) { + Log.Warn("NOT IMPLEMENTED."); return [];//TODO } } \ No newline at end of file diff --git a/API/Schema/MangaConnectors/AsuraToon.cs b/API/Schema/MangaConnectors/AsuraToon.cs index 214ce4b..8437bbb 100644 --- a/API/Schema/MangaConnectors/AsuraToon.cs +++ b/API/Schema/MangaConnectors/AsuraToon.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using API.MangaDownloadClients; using HtmlAgilityPack; +using log4net; namespace API.Schema.MangaConnectors; diff --git a/API/Schema/MangaConnectors/MangaConnector.cs b/API/Schema/MangaConnectors/MangaConnector.cs index c741f40..09d9856 100644 --- a/API/Schema/MangaConnectors/MangaConnector.cs +++ b/API/Schema/MangaConnectors/MangaConnector.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.RegularExpressions; using API.MangaDownloadClients; +using log4net; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; @@ -36,6 +37,10 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s [JsonIgnore] [NotMapped] internal DownloadClient downloadClient { get; init; } = null!; + + [JsonIgnore] + [NotMapped] + protected ILog Log { get; init; } = LogManager.GetLogger(name); public Chapter[] GetNewChapters(Manga manga) { diff --git a/API/Schema/NotificationConnectors/NotificationConnector.cs b/API/Schema/NotificationConnectors/NotificationConnector.cs index 0bd8f3f..b65d455 100644 --- a/API/Schema/NotificationConnectors/NotificationConnector.cs +++ b/API/Schema/NotificationConnectors/NotificationConnector.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text; +using log4net; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; @@ -35,10 +36,15 @@ public class NotificationConnector(string name, string url, Dictionary formattedHeaders = Headers.ToDictionary(h => h.Key, @@ -48,8 +54,10 @@ public class NotificationConnector(string name, string url, Dictionary RunningJobs = new(); private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga)); internal static void StartLogger() { BasicConfigurator.Configure(); + Log.Info("Logger Configured."); } - private static void NotificationSender(object? pgsqlContext) + private static void NotificationSender(object? serviceProviderObj) { - if(pgsqlContext is null) return; - PgsqlContext context = (PgsqlContext)pgsqlContext; + if (serviceProviderObj is null) + { + Log.Error("serviceProviderObj is null"); + return; + } + IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj!; + using IServiceScope scope = serviceProvider.CreateScope(); + PgsqlContext? context = scope.ServiceProvider.GetService(); + if (context is null) + { + Log.Error("PgsqlContext is null"); + return; + } - IQueryable staleNotifications = context.Notifications.Where(n => n.Urgency < NotificationUrgency.Normal); - context.Notifications.RemoveRange(staleNotifications); - context.SaveChanges(); + try + { + //Removing Notifications from previous runs + IQueryable staleNotifications = + context.Notifications.Where(n => n.Urgency < NotificationUrgency.Normal); + context.Notifications.RemoveRange(staleNotifications); + context.SaveChanges(); + } + catch (DbUpdateException e) + { + Log.Error("Error removing stale notifications.", e); + } + while (true) { - SendNotifications(context, NotificationUrgency.High); - SendNotifications(context, NotificationUrgency.Normal); - SendNotifications(context, NotificationUrgency.Low); + SendNotifications(serviceProvider, NotificationUrgency.High); + SendNotifications(serviceProvider, NotificationUrgency.Normal); + SendNotifications(serviceProvider, NotificationUrgency.Low); - context.SaveChanges(); Thread.Sleep(2000); } } - private static void SendNotifications(PgsqlContext context, NotificationUrgency urgency) + private static void SendNotifications(IServiceProvider serviceProvider, NotificationUrgency urgency) { - List notifications = context.Notifications.Where(n => n.Urgency == urgency).ToList(); - if (notifications.Any()) + Log.Info($"Sending notifications for {urgency}"); + using IServiceScope scope = serviceProvider.CreateScope(); + PgsqlContext? context = scope.ServiceProvider.GetService(); + if (context is null) { - DateTime max = notifications.MaxBy(n => n.Date)!.Date; - if (DateTime.UtcNow.Subtract(max) > TrangaSettings.NotificationUrgencyDelay(urgency)) - { - foreach (NotificationConnector notificationConnector in context.NotificationConnectors) - { - foreach (Notification notification in notifications) - notificationConnector.SendNotification(notification.Title, notification.Message); - } - context.Notifications.RemoveRange(notifications); - } + Log.Error("PgsqlContext is null"); + return; + } + + List notifications = context.Notifications.Where(n => n.Urgency == urgency).ToList(); + if (!notifications.Any()) + return; + + try + { + foreach (NotificationConnector notificationConnector in context.NotificationConnectors) + { + foreach (Notification notification in notifications) + notificationConnector.SendNotification(notification.Title, notification.Message); + } + + context.Notifications.RemoveRange(notifications); + context.SaveChangesAsync(); + } + catch (DbUpdateException e) + { + Log.Error("Error sending notifications.", e); } - context.SaveChanges(); } + private const string TRANGA = + "\n\n" + + " _______ \n" + + "|_ _|.----..---.-..-----..-----..---.-.\n" + + " | | | _|| _ || || _ || _ |\n" + + " |___| |__| |___._||__|__||___ ||___._|\n" + + " |_____| \n\n"; + private static readonly Dictionary RunningJobs = new(); private static void JobStarter(object? serviceProviderObj) { - if(serviceProviderObj is null) return; + if (serviceProviderObj is null) + { + Log.Error("serviceProviderObj is null"); + return; + } IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj; using IServiceScope scope = serviceProvider.CreateScope(); PgsqlContext? context = scope.ServiceProvider.GetService(); - if (context is null) return; + if (context is null) + { + Log.Error("PgsqlContext is null"); + return; + } - string TRANGA = - "\n\n _______ \n|_ _|.----..---.-..-----..-----..---.-.\n | | | _|| _ || || _ || _ |\n |___| |__| |___._||__|__||___ ||___._|\n |_____| \n\n"; Log.Info(TRANGA); + Log.Info("JobStarter Thread running."); while (true) { List completedJobs = context.Jobs.Where(j => j.state >= JobState.Completed).ToList(); + Log.Debug($"Completed jobs: {completedJobs.Count}"); foreach (Job job in completedJobs) if (job.RecurrenceMs <= 0) context.Jobs.Remove(job); @@ -82,16 +131,20 @@ public static class Tranga else job.state = JobState.Waiting; job.LastExecution = DateTime.UtcNow; - context.Jobs.Update(job); } List runJobs = context.Jobs.Where(j => j.state <= JobState.Running && j.Enabled == true).ToList() .Where(j => j.NextExecution < DateTime.UtcNow).ToList(); - foreach (Job job in OrderJobs(runJobs, context)) + Log.Debug($"Due jobs: {runJobs.Count}"); + Log.Debug($"Running jobs: {RunningJobs.Count}"); + IEnumerable orderedJobs = OrderJobs(runJobs, context).ToList(); + Log.Debug($"Ordered jobs: {orderedJobs.Count()}"); + foreach (Job job in orderedJobs) { // If the job is already running, skip it if (RunningJobs.Values.Any(j => j.JobId == job.JobId)) continue; + //If a Job for that connector is already running, skip it if (job is DownloadAvailableChaptersJob dncj) { if (RunningJobs.Values.Any(j => @@ -113,15 +166,15 @@ public static class Tranga Thread t = new(() => { - IEnumerable newJobs = job.Run(serviceProvider); + job.Run(serviceProvider); }); RunningJobs.Add(t, job); t.Start(); - context.Jobs.Update(job); } (Thread, Job)[] removeFromThreadsList = RunningJobs.Where(t => !t.Key.IsAlive) .Select(t => (t.Key, t.Value)).ToArray(); + Log.Debug($"Remove from Threads List: {removeFromThreadsList.Length}"); foreach ((Thread thread, Job job) thread in removeFromThreadsList) { RunningJobs.Remove(thread.thread); @@ -135,7 +188,7 @@ public static class Tranga } catch (DbUpdateException e) { - + Log.Error("Failed saving Job changes.", e); } Thread.Sleep(TrangaSettings.startNewJobTimeoutMs); }