diff --git a/CustomMetadataDB.Tests/UtilsTests.cs b/CustomMetadataDB.Tests/UtilsTests.cs index ef96490..72c9efa 100644 --- a/CustomMetadataDB.Tests/UtilsTests.cs +++ b/CustomMetadataDB.Tests/UtilsTests.cs @@ -72,6 +72,34 @@ public void Test_get_series_id_from_json(string dir, string expected) Assert.Equal(expected, result); } + [Fact] + public void Test_file_to_info() + { + var path = "/home/media/test/201012 foobar ep24 - ahmed [foo].mkv"; + var item = Utils.FileToInfo(path, new DateTime(2021, 1, 1, 01, 02, 03, DateTimeKind.Utc)); + Assert.Equal(110120203, item.IndexNumber); + Assert.Equal(202010, item.ParentIndexNumber); + Assert.Equal(2020, item.Year); + Assert.Equal("ep24 - ahmed", item.Name); + Assert.Equal($"{item.IndexNumber}", item.ProviderIds[Constants.PLUGIN_EXTERNAL_ID]); + } + + [Fact] + public void Test_ToEpisode() + { + var path = "/home/media/test/201012 foobar ep24 - ahmed [foo].mkv"; + var result = Utils.ToEpisode(Utils.FileToInfo(path, new DateTime(2021, 1, 1, 01, 02, 03, DateTimeKind.Utc))); + + Assert.True(result.HasMetadata); + + var item = result.Item; + + Assert.Equal(110120203, item.IndexNumber); + Assert.Equal(202010, item.ParentIndexNumber); + Assert.Equal("ep24 - ahmed", item.Name); + Assert.Equal($"{item.IndexNumber}", item.ProviderIds[Constants.PLUGIN_EXTERNAL_ID]); + } + private static ILogger SetLogger() { using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); diff --git a/CustomMetadataDB/CustomMetadataDB.csproj b/CustomMetadataDB/CustomMetadataDB.csproj index ebd2019..fdc920c 100644 --- a/CustomMetadataDB/CustomMetadataDB.csproj +++ b/CustomMetadataDB/CustomMetadataDB.csproj @@ -2,7 +2,7 @@ net6.0 CustomMetadataDB - 0.0.0.2 + 1.0.0.0 $(Version) $(Version) true diff --git a/CustomMetadataDB/ExternalId.cs b/CustomMetadataDB/ExternalId.cs index fa21eb4..64141ae 100644 --- a/CustomMetadataDB/ExternalId.cs +++ b/CustomMetadataDB/ExternalId.cs @@ -14,4 +14,13 @@ public class SeriesExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Series; public string UrlFormatString => Plugin.Instance.Configuration.ApiRefUrl; } + + public class EpisodeExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) => item is Episode; + public string ProviderName => Constants.PLUGIN_NAME; + public string Key => Constants.PLUGIN_EXTERNAL_ID; + public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; + public string UrlFormatString => Plugin.Instance.Configuration.ApiRefUrl; + } } diff --git a/CustomMetadataDB/Helpers/Constants.cs b/CustomMetadataDB/Helpers/Constants.cs index de2b927..29c8039 100644 --- a/CustomMetadataDB/Helpers/Constants.cs +++ b/CustomMetadataDB/Helpers/Constants.cs @@ -1,4 +1,6 @@ -namespace CustomMetadataDB.Helpers +using System.Text.RegularExpressions; + +namespace CustomMetadataDB.Helpers { public class Constants { @@ -6,5 +8,16 @@ public class Constants public const string PLUGIN_EXTERNAL_ID = "cmdb"; public const string PLUGIN_DESCRIPTION = "Custom metadata agent db."; public const string PLUGIN_GUID = "83b77e24-9fce-4ee0-a794-73fdfa972e66"; + + public static readonly Regex[] EPISODE_MATCHERS = { + // YY?YY(-._)MM(-._)DD -? series -? epNumber -? title + new(@"^(?\d{2,4})(\-|\.|_)?(?\d{2})(\-|\.|_)?(?\d{2})\s-?(?.+?)(?\#(\d+)|ep(\d+)|DVD[0-9.-]+|DISC[0-9.-]+|SP[0-9.-]+|Episode\s(\d+)) -?(?.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // YY?YY(-._)MM(-._)DD -? title + new(@"^(?<year>\d{2,4})(\-|\.|_)?(?<month>\d{2})(\-|\.|_)?(?<day>\d{2})\s?-?(?<title>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // title YY?YY(-._)MM(-._)DD at end of filename. + new(@"(?<title>.+?)(?<year>\d{2,4})(\-|\.|_)?(?<month>\d{2})(\-|\.|_)?(?<day>\d{2})$", RegexOptions.Compiled | RegexOptions.IgnoreCase), + // series - YY?YY(-._)MM(-._)DD -? title + new(@"(?<series>.+?)(?<year>\d{2,4})(\-|\.|_)?(?<month>\d{2})(\-|\.|_)?(?<day>\d{2})\s?-?(?<title>.+)?", RegexOptions.Compiled | RegexOptions.IgnoreCase) + }; } } diff --git a/CustomMetadataDB/Helpers/Utils.cs b/CustomMetadataDB/Helpers/Utils.cs index 853738c..9bad29c 100644 --- a/CustomMetadataDB/Helpers/Utils.cs +++ b/CustomMetadataDB/Helpers/Utils.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using System; +using System.Text.RegularExpressions; namespace CustomMetadataDB.Helpers { @@ -21,6 +22,7 @@ public class Utils public static ILogger Logger { get; set; } = null; public static PersonInfo CreatePerson(string name, string provider_id) + { return new PersonInfo { @@ -31,7 +33,7 @@ public static PersonInfo CreatePerson(string name, string provider_id) } public static MetadataResult<Series> ToSeries(DTO data) { - Logger?.LogInformation($"Processing {data}."); + Logger?.LogDebug($"Processing {data}."); var item = new Series(); @@ -84,13 +86,158 @@ public static MetadataResult<Series> ToSeries(DTO data) }; } + public static EpisodeInfo FileToInfo(string file, DateTime? file_date = null) + { + + //-- get only the file stem + string filename = System.IO.Path.GetFileNameWithoutExtension(file); + + Match matcher = null; + + for (int i = 0; i < Constants.EPISODE_MATCHERS.Length; i++) + { + matcher = Constants.EPISODE_MATCHERS[i].Match(filename); + if (!matcher.Success) + { + continue; + } + break; + } + + if (!matcher.Success) + { + Logger?.LogInformation($"No match found for {file}."); + return new EpisodeInfo(); + } + + string series = matcher.Groups["series"].Success ? matcher.Groups["series"].Value : ""; + string year = matcher.Groups["year"].Success ? matcher.Groups["year"].Value : ""; + year = (year.Length == 2) ? "20" + year : year; + string month = matcher.Groups["month"].Success ? matcher.Groups["month"].Value : ""; + string day = matcher.Groups["day"].Success ? matcher.Groups["day"].Value : ""; + string episode = matcher.Groups["episode"].Success ? matcher.Groups["episode"].Value : ""; + + string season = matcher.Groups["season"].Success ? matcher.Groups["season"].Value : ""; + season = season == "" ? year + month : season; + + string broadcastDate = (year != "" && month != "" && day != "") ? year + "-" + month + "-" + day : ""; + if (broadcastDate == "" && file_date != null) + { + broadcastDate = file_date?.ToString("yyyy-MM-dd") ?? ""; + } + + string title = matcher.Groups["title"].Success ? matcher.Groups["title"].Value : ""; + if (title != "") + { + if (!string.IsNullOrEmpty(series) && title != series && title.ToLower().Contains(series.ToLower())) + { + title = title.Replace(series, "", StringComparison.OrdinalIgnoreCase).Trim(); + } + + if (title == "" && title == series && broadcastDate != "") + { + title = broadcastDate; + } + + // -- replace double spaces with single space + title = Regex.Replace(title, @"\[.+?\]", " ").Trim('-').Trim(); + title = Regex.Replace(title, @"\s+", " "); + title = title.Trim().Trim('-').Trim(); + + if (matcher.Groups["epNumber"].Success) + { + title = matcher.Groups["epNumber"].Value + " - " + title; + } + else if (title != "" && broadcastDate != "" && broadcastDate != title) + { + title = $"{broadcastDate.Replace("-", "")} ~ {title}"; + } + } + + if (episode == "") + { + episode = "1" + month + day; + + // get the modified date of the file + if (System.IO.File.Exists(file) || file_date != null) + { + episode += (file_date ?? System.IO.File.GetLastWriteTimeUtc(file)).ToString("mmss"); + } + } + + episode = (episode == "") ? int.Parse('1' + month + day).ToString() : episode; + + EpisodeInfo item = new() + { + IndexNumber = int.Parse(episode), + Name = title, + Path = file, + Year = int.Parse(year), + ParentIndexNumber = int.Parse(season) + }; + + item.SetProviderId(Constants.PLUGIN_EXTERNAL_ID, item.IndexNumber.ToString()); + + // -- Set the PremiereDate if we have a year, month and day + if (year != "" && month != "" && day != "") + { + item.PremiereDate = new DateTime(int.Parse(year), int.Parse(month), int.Parse(day)); + } + + return item; + } + + + public static MetadataResult<Episode> ToEpisode(EpisodeInfo data) + { + if (data.Path == "") + { + Logger?.LogInformation($"No metadata found for '{data.Path}'."); + return ErrorOutEpisode(); + } + + Logger?.LogDebug($"Processing {data}."); + + Episode item = new() + { + Name = data.Name, + IndexNumber = data.IndexNumber, + ParentIndexNumber = data.ParentIndexNumber, + Path = data.Path, + }; + + if (data.PremiereDate is DateTime time) + { + item.PremiereDate = time; + item.ProductionYear = time.Year; + item.ForcedSortName = time.ToString("yyyyMMdd") + '-' + item.Name; + } + + item.SetProviderId(Constants.PLUGIN_EXTERNAL_ID, data.ProviderIds[Constants.PLUGIN_EXTERNAL_ID]); + + return new MetadataResult<Episode> + { + HasMetadata = true, + Item = item + }; + } + private static MetadataResult<Series> ErrorOut() { return new MetadataResult<Series> { - HasMetadata = true, + HasMetadata = false, Item = new Series() }; } + + private static MetadataResult<Episode> ErrorOutEpisode() + { + return new MetadataResult<Episode> + { + HasMetadata = false, + Item = new Episode() + }; + } } } diff --git a/CustomMetadataDB/Provider/AbstractProvider.cs b/CustomMetadataDB/Provider/AbstractProvider.cs deleted file mode 100644 index a92263c..0000000 --- a/CustomMetadataDB/Provider/AbstractProvider.cs +++ /dev/null @@ -1,107 +0,0 @@ -using CustomMetadataDB.Helpers; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using System.Web; - -namespace CustomMetadataDB -{ - public abstract class AbstractProvider<B, T, E> : IRemoteMetadataProvider<T, E> - where T : BaseItem, IHasLookupInfo<E> - where E : ItemLookupInfo, new() - { - protected readonly IServerConfigurationManager _config; - protected readonly IHttpClientFactory _httpClientFactory; - protected readonly ILogger<B> _logger; - protected readonly IFileSystem _fileSystem; - - public AbstractProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, ILogger<B> logger) - { - _config = config; - _logger = logger; - Utils.Logger = logger; - _fileSystem = fileSystem; - _httpClientFactory = httpClientFactory; - - } - - public virtual string Name { get; } = Constants.PLUGIN_NAME; - - public virtual Task<MetadataResult<T>> GetMetadata(E info, CancellationToken cancellationToken) - { - _logger.LogDebug("CMD GetMetadata: {Path}", info.Path); - - return GetMetadataImpl(info, cancellationToken); - } - - internal abstract Task<MetadataResult<T>> GetMetadataImpl(E data, CancellationToken cancellationToken); - - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(E searchInfo, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug($"CMD Series GetMetadata: {searchInfo.Name} ({searchInfo.Path})"); - - var result = new List<RemoteSearchResult>(); - - try - { - using var httpResponse = await QueryAPI("series", searchInfo.Name, cancellationToken, limit: 20).ConfigureAwait(false); - - if (httpResponse.StatusCode != HttpStatusCode.OK) - { - _logger.LogInformation($"CMD Series GetMetadata: {searchInfo.Name} ({searchInfo.Path}) - Status Code: {httpResponse.StatusCode}"); - return result; - } - - DTO[] seriesRootObject = await JsonSerializer.DeserializeAsync<DTO[]>( - utf8Json: await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - options: Utils.JSON_OPTS, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - foreach (var series in seriesRootObject) - { - result.Add(new RemoteSearchResult - { - Name = series.Title, - ProviderIds = new Dictionary<string, string> { { Constants.PLUGIN_EXTERNAL_ID, series.Id } }, - }); - } - - _logger.LogDebug($"CMD Series GetMetadata Result: {result}"); - return result; - } - catch (HttpRequestException exception) - { - if (exception.StatusCode.HasValue && exception.StatusCode.Value == HttpStatusCode.NotFound) - { - return result; - } - - throw; - } - } - public virtual Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => throw new NotImplementedException(); - - protected Task<HttpResponseMessage> QueryAPI(string type, string name, CancellationToken cancellationToken, int limit = 1) - { - var apiUrl = Plugin.Instance.Configuration.ApiUrl; - apiUrl += string.IsNullOrEmpty(new Uri(apiUrl).Query) ? "?" : "&"; - apiUrl += $"type={type}&limit={limit}&&query={HttpUtility.UrlEncode(name)}"; - - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(new Uri(apiUrl), cancellationToken); - } - } -} diff --git a/CustomMetadataDB/Provider/EpisodeProvider.cs b/CustomMetadataDB/Provider/EpisodeProvider.cs new file mode 100644 index 0000000..371579e --- /dev/null +++ b/CustomMetadataDB/Provider/EpisodeProvider.cs @@ -0,0 +1,62 @@ +using CustomMetadataDB.Helpers; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace CustomMetadataDB; + +public class EpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasItemChangeMonitor +{ + protected readonly ILogger<EpisodeProvider> _logger; + + public EpisodeProvider(ILogger<EpisodeProvider> logger) + { + _logger = logger; + Utils.Logger = logger; + } + + public string Name => Constants.PLUGIN_NAME; + + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + _logger.LogDebug($"DEP HasChanged: {item.Path}"); + + FileSystemMetadata fileInfo = directoryService.GetFile(item.Path); + var result = fileInfo.Exists && fileInfo.LastWriteTimeUtc.ToUniversalTime() > item.DateLastSaved.ToUniversalTime(); + + string status = result ? "Has Changed" : "Has Not Changed"; + + _logger.LogDebug($"DEP HasChanged Result: {status}"); + + return result; + } + + public Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) + { + MetadataResult<Episode> result = new() { HasMetadata = false }; + + cancellationToken.ThrowIfCancellationRequested(); + _logger.LogDebug($"CMD Episode GetMetadata Lookup: '{info.Name}' '({info.Path})'"); + + var item = Utils.FileToInfo(info.Path); + if (item.Path == "") + { + _logger.LogWarning($"CMD Episode GetMetadata: No metadata found for '{info.Path}'."); + return Task.FromResult(result); + } + + return Task.FromResult(Utils.ToEpisode(item)); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) => throw new NotImplementedException(); + + public virtual Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => throw new NotImplementedException(); +} diff --git a/CustomMetadataDB/Provider/SeriesProvider.cs b/CustomMetadataDB/Provider/SeriesProvider.cs index d6fc2da..13a939d 100644 --- a/CustomMetadataDB/Provider/SeriesProvider.cs +++ b/CustomMetadataDB/Provider/SeriesProvider.cs @@ -1,57 +1,129 @@ -using MediaBrowser.Controller.Entities.TV; +using CustomMetadataDB.Helpers; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; +using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using CustomMetadataDB.Helpers; -using MediaBrowser.Controller.Configuration; +using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using System.Text.Json; +using System.Web; +using MediaBrowser.Controller.Entities.TV; -namespace CustomMetadataDB +namespace CustomMetadataDB; + +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> { - public class SeriesProvider : AbstractProvider<SeriesProvider, Series, SeriesInfo> + protected readonly IServerConfigurationManager _config; + protected readonly IHttpClientFactory _httpClientFactory; + protected readonly ILogger<SeriesProvider> _logger; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger) + { + _logger = logger; + Utils.Logger = logger; + _httpClientFactory = httpClientFactory; + } + + public string Name => Constants.PLUGIN_NAME; + + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { - public SeriesProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IFileSystem fileSystem, ILogger<SeriesProvider> logger) : - base(config, httpClientFactory, fileSystem, logger) - { } - internal override async Task<MetadataResult<Series>> GetMetadataImpl(SeriesInfo info, CancellationToken cancellationToken) + cancellationToken.ThrowIfCancellationRequested(); + + MetadataResult<Series> result = new(); + _logger.LogDebug($"CMD Series GetMetadata: {info.Name} ({info.Path})"); + + try { - cancellationToken.ThrowIfCancellationRequested(); + using var httpResponse = await QueryAPI("series", info.Name, cancellationToken).ConfigureAwait(false); + + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + _logger.LogInformation($"CMD Series GetMetadata: {info.Name} ({info.Path}) - Status Code: {httpResponse.StatusCode}"); + return result; + } - MetadataResult<Series> result = new(); - _logger.LogDebug($"CMD Series GetMetadata: {info.Name} ({info.Path})"); + DTO[] seriesRootObject = await JsonSerializer.DeserializeAsync<DTO[]>( + utf8Json: await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + options: Utils.JSON_OPTS, + cancellationToken: cancellationToken + ).ConfigureAwait(false); - try + _logger.LogDebug($"CMD Series GetMetadata Result: {seriesRootObject}"); + return Utils.ToSeries(seriesRootObject[0]); + } + catch (HttpRequestException exception) + { + if (exception.StatusCode.HasValue && exception.StatusCode.Value == HttpStatusCode.NotFound) { - using var httpResponse = await QueryAPI("series",info.Name, cancellationToken).ConfigureAwait(false); + return result; + } - if (httpResponse.StatusCode != HttpStatusCode.OK) - { - _logger.LogInformation($"CMD Series GetMetadata: {info.Name} ({info.Path}) - Status Code: {httpResponse.StatusCode}"); - return result; - } - - DTO[] seriesRootObject = await JsonSerializer.DeserializeAsync<DTO[]>( - utf8Json: await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - options: Utils.JSON_OPTS, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - _logger.LogDebug($"CMD Series GetMetadata Result: {seriesRootObject}"); - return Utils.ToSeries(seriesRootObject[0]); + throw; + } + } + + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug($"CMD Series GetMetadata: {searchInfo.Name} ({searchInfo.Path})"); + + var result = new List<RemoteSearchResult>(); + + try + { + using var httpResponse = await QueryAPI("series", searchInfo.Name, cancellationToken, limit: 20).ConfigureAwait(false); + + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + _logger.LogInformation($"CMD Series GetMetadata: {searchInfo.Name} ({searchInfo.Path}) - Status Code: {httpResponse.StatusCode}"); + return result; } - catch (HttpRequestException exception) + + DTO[] seriesRootObject = await JsonSerializer.DeserializeAsync<DTO[]>( + utf8Json: await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + options: Utils.JSON_OPTS, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + foreach (var series in seriesRootObject) { - if (exception.StatusCode.HasValue && exception.StatusCode.Value == HttpStatusCode.NotFound) + result.Add(new RemoteSearchResult { - return result; - } + Name = series.Title, + ProviderIds = new Dictionary<string, string> { { Constants.PLUGIN_EXTERNAL_ID, series.Id } }, + }); + } - throw; + _logger.LogDebug($"CMD Series GetMetadata Result: {result}"); + return result; + } + catch (HttpRequestException exception) + { + if (exception.StatusCode.HasValue && exception.StatusCode.Value == HttpStatusCode.NotFound) + { + return result; } + + throw; } } + + protected Task<HttpResponseMessage> QueryAPI(string type, string name, CancellationToken cancellationToken, int limit = 1) + { + var apiUrl = Plugin.Instance.Configuration.ApiUrl; + apiUrl += string.IsNullOrEmpty(new Uri(apiUrl).Query) ? "?" : "&"; + apiUrl += $"type={type}&limit={limit}&&query={HttpUtility.UrlEncode(name)}"; + + return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(new Uri(apiUrl), cancellationToken); + } + + public virtual Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => throw new NotImplementedException(); } diff --git a/build.yaml b/build.yaml index 156eac6..869c4aa 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ --- name: "Custom metadata agent db" guid: "83b77e24-9fce-4ee0-a794-73fdfa972e66" -version: "0.0.0.2" +version: "1.0.0.0" targetAbi: "10.7.0.0" owner: "arabcoders" overview: "A Custom metadata agent."