For developers — how to write your own download or metadata provider
Abigail's Media Renamer loads third-party plugins from .dll files
dropped into a plugins/ folder next to the app executable. Two kinds
of plugin are supported: download providers (search a torrent/NZB/usenet indexer)
and metadata providers (search for TV series/movies, alongside the built-in
TMDB/TVDB lookups). This exists so piracy-adjacent indexer scrapers can stay out of the shipped
app — you write and host your own DLL, and anyone who wants that indexer installs it themselves.
📦 You don't need this app's source code
The interfaces you implement live in a small, standalone assembly —
AbigailsMediaRenamer.Contracts.dll — which ships inside every
install. Reference it straight from an installed copy of the app; no repo access required.
Install Abigail's Media Renamer normally. By default it lands in
C:\Program Files\AbigailsMediaRenamer\ — you'll find
AbigailsMediaRenamer.Contracts.dll sitting right there next to
AbigailsMediaRenamer.exe.
Create a new .NET class library project targeting the same TFM as the main
app (currently net10.0-windows — right-click the installed
.exe → Properties → Details if you want to double-check).
Reference the installed DLL directly in your .csproj — no project or NuGet reference needed:
<ItemGroup>
<Reference Include="AbigailsMediaRenamer.Contracts">
<HintPath>C:\Program Files\AbigailsMediaRenamer\AbigailsMediaRenamer.Contracts.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<Private>false</Private> stops your build copying this DLL into your own output — it's already sitting next to the host app at runtime, so you don't need to ship your own copy.
Implement IDownloadProvider or IMetadataProvider (both extend the base IPlugin).
Build, then drop your compiled DLL (and any of its own other dependencies — not AbigailsMediaRenamer.Contracts.dll itself) into the plugins/ folder next to AbigailsMediaRenamer.exe.
Launch the app. Your plugin is discovered, you'll get a one-time trust prompt (see below), then it shows up in Settings → Plugins for enabling and configuration.
The bundled Newznab plugin (a generic usenet-indexer client) is a
complete, working IDownloadProvider example. It's not open source, but
it ships as AbigailsMediaRenamer.UsenetPlugins.dll right next to
Contracts.dll in your install folder — a decompiler (ILSpy, dotPeek)
will show you exactly how it's built, or just email me for the source.
Every plugin — download or metadata — implements this:
public interface IPlugin
{
string PluginId { get; } // unique across all plugins, e.g. "MyMetadataProvider"
string DisplayName { get; } // shown in Settings
string Version { get; } // informational only
PluginType Type { get; } // MetadataProvider or DownloadProvider
string Description { get; } // shown in Settings
bool IsEnabled { get; set; } // set BY the app from saved settings — don't set this yourself
ConfigField[] GetConfigSchema();
bool Initialize(Dictionary<string, string> config);
}
settings.json.
Don't rename it once users have configured your plugin; they'll lose their saved config.Type == DownloadProvider but you
don't implement IDownloadProvider (or vice versa) — get it right so
your plugin is actually found by the search code, which looks it up by interface, not by Type.Initialize. Use
Initialize's return value for your own "am I ready" logic instead.GetConfigSchema() declares what configuration your plugin needs; the app
builds the Settings form for you from this — you never write UI code.
public class ConfigField
{
public string Key { get; } // dictionary key passed back into Initialize
public string Type { get; } // "string", "url", "bool" — see below
public string Label { get; } // field label in the UI
public string? Placeholder { get; } // shown as a tooltip/hint
public bool IsSecret { get; } // renders as a masked PasswordBox
public bool IsRequired { get; } // shows a red "*" — not currently enforced before save
public string? DefaultValue { get; }
}
Field rendering rules:
Type == "bool" → checkbox.IsSecret == true → password box (masked). Use this for API keys/tokens."string", "url") → a plain text box. Type beyond "bool" is currently informational rather than driving different widgets or validation — don't rely on "url" getting URL-specific validation for free.Called once at startup, after the user has enabled your plugin and filled in the config form.
config contains your ConfigField keys mapped
to whatever the user typed (or the field's default, if untouched). One key is injected
automatically regardless of your schema:
config["FlareSolverrUrl"] — the user's global FlareSolverr URL
setting (empty string if unset). Don't declare your own ConfigField
for this; just read it if your indexer needs Cloudflare bypass, and declare
RequiresFlareSolverr => true on IDownloadProvider
so the app warns the user if they haven't set one.Return false if the config is invalid (missing required
values, malformed URL, etc.) — the app logs this and skips loading your plugin entirely for that
session rather than calling any of your search methods. There's no separate "test connection"
button in the current UI, so this is your only validation hook.
public interface IDownloadProvider : IPlugin
{
string[] SupportedContentTypes => Array.Empty<string>(); // default: all types
bool RequiresFlareSolverr => false;
Task<List<DownloadResult>> SearchAsync(
string query,
CancellationToken cancellationToken = default);
}
["Movie", "TV"]
or ["Audiobook"] restricts which library types will query your
plugin. Leave the default (empty array) if your indexer searches everything.true if your indexer
sits behind Cloudflare and needs the proxy; the download search UI shows a warning banner if
FlareSolverrUrl isn't configured.public class DownloadResult
{
public string Title { get; set; }
public string DownloadLink { get; set; } // magnet link, .torrent URL, or .nzb URL
public string? Size { get; set; } // pre-formatted string, e.g. "4.2 GB" — your job to format
public int? Seeders { get; set; } // torrent-specific; leave null for usenet
public int? Leechers { get; set; }
public DateTime? UploadedDate { get; set; }
public string? ProviderName { get; set; } // shown alongside each result; defaults sensibly if omitted
}
Size is a display string, not bytes — format it yourself (KB/MB/GB
thresholds, empty string for zero/unknown is the simplest approach).
public interface IMetadataProvider : IPlugin
{
Task<List<SeriesSearchResult>> SearchSeriesAsync(
string title, int? year = null, CancellationToken cancellationToken = default);
Task<List<MovieSearchResult>> SearchMovieAsync(
string title, int? year = null, CancellationToken cancellationToken = default);
Task<List<EpisodeInfo>> GetSeasonEpisodesAsync(
string seriesId, int seasonNumber, CancellationToken cancellationToken = default);
}
Your results are merged alongside the built-in providers (TMDB, TVDB, etc.) in the same search — implement whichever of the three methods make sense for your source; return an empty list from the others if, say, you're TV-only.
public class SeriesSearchResult
{
public string ProviderId { get; set; } // YOUR id for this series — round-trips back to you
public string Title { get; set; }
public int? Year { get; set; }
public string? Description { get; set; }
public int? EpisodeCount { get; set; }
public double? Confidence { get; set; } // 0-1 if you can score match quality; null is fine
}
ProviderId matters: whatever you put here is the seriesId
the app hands back to your own GetSeasonEpisodesAsync later, once the
user picks your result — it's not the app's ID, it's yours, so use whatever your backend needs to
look the series up again (a slug, a numeric ID, a URL fragment), just be consistent with yourself.
MovieSearchResult is the same shape minus EpisodeCount
(movies have no season call). EpisodeInfo (returned from
GetSeasonEpisodesAsync) is EpisodeNumber,
Title, AirDate, Description
— no ProviderId, since there's no further round-trip after this.
Understanding this will save you support requests from your plugin's users:
.dll in
plugins/ and logs the hash.newznab is
auto-trusted — a first-party carve-out for the bundled indexer plugin, not something you can
opt into by renaming your own DLL. Users should decide to trust your plugin deliberately.Initialize() if
the user has toggled it on in Settings.IPlugin
implementations, only the first one found is loaded — ship one plugin type per DLL.Since trust and config are both keyed by DLL identity, a user who wants to query two different
same-protocol indexers just copies the DLL under a second filename — each copy gets
its own trust prompt, its own entry in Settings, and its own Initialize()
config. You don't need to build multi-instance support into your plugin for this to work, as long as
your PluginId and config are self-contained per loaded instance.
plugins/
— there's no NuGet restore step at runtime, the loader just loads what's sitting next to your
plugin DLL (and what's already loaded in the host process).false instead —
an exception there is caught and logged the same as any other plugin error, but a clean
false is easier for you to reason about and easier for users to
diagnose from the log.HttpClient reused for the lifetime of your plugin instance is the
recommended .NET pattern — avoid creating a new one per search call.A note on staying current
This guide is written against the real interfaces and loader code, but the app's source isn't public, so you can't browse it yourself to double-check. If something here seems off against the DLL you're actually referencing, trust the DLL — decompile it (ILSpy or dotPeek both handle this fine) rather than assuming this page is current. The interfaces are small and stable by design, so drift should be rare, but this guide can lag behind a shipped release.
Questions, or want the Newznab plugin's source as a starting template?
✉ Email Abigail