diff --git a/docs/docs/configuration/_include/config-file-sample.yml b/docs/docs/configuration/_include/config-file-sample.yml
index b49c425f..c08974bd 100644
--- a/docs/docs/configuration/_include/config-file-sample.yml
+++ b/docs/docs/configuration/_include/config-file-sample.yml
@@ -241,6 +241,14 @@ radarr:
- template: radarr-quality-definition-movie
- template: radarr-quality-profile-hd-bluray-web
- template: radarr-custom-formats-hd-bluray-web
+ # Include a template by path. Relative paths resolve against this config's folder.
+ # - template: ./profiles/radarr/extra.yml
+
+ # Reusable, name-independent profiles: the included file carries no profile name,
+ # so the same file can be bound to a different name per instance.
+ # profiles:
+ # - name: UHD
+ # includes: ./profiles/radarr/quality.yml
custom_formats: # Custom format assignments
- trash_ids:
diff --git a/docs/docs/configuration/config-file.md b/docs/docs/configuration/config-file.md
index 065c9392..146a8c42 100644
--- a/docs/docs/configuration/config-file.md
+++ b/docs/docs/configuration/config-file.md
@@ -96,6 +96,34 @@ sonarr:
- When loading TRaSH-Guides templates from URLs, specify `source: TRASH` in the include entry
- Network requests have a 30-second timeout
+4. **File Templates** Experimental: Include a template by its path on disk
+
+ - Lets you organise templates in subfolders instead of the single flat `localConfigTemplatesPath` directory
+ - The same file can be included from several instances, so a profile lives in exactly one place
+ - Any `include` entry whose `template` contains a `/` or `\`, or ends in `.yml` / `.yaml` / `.json`, is treated as a path
+
+```yaml title="config.yml"
+radarr:
+ movies:
+ # ...
+ include:
+ # Relative paths resolve against the folder containing your config.yml
+ - template: ./profiles/radarr/uhd.yml
+ # Absolute paths are used as-is
+ - template: /data/profiles/shared/audio.yml
+ # TRaSH-Guides JSON is detected automatically from its trash_id
+ - template: ./profiles/radarr/trash-profile.json
+```
+
+**Notes:**
+
+- Relative paths are resolved against the **directory holding `config.yml`** (see `CONFIG_LOCATION`), never the working directory, so the same config behaves identically however configarr is started
+- Both Recyclarr-format YAML and TRaSH-Guides-format JSON are accepted. A file containing a `trash_id` is treated as TRaSH automatically, so `source: TRASH` is optional
+- A template _name_ always wins: if the value also matches a known Recyclarr, local or TRaSH template, that template is used and the file is never read
+- File templates are processed **last**, so they take precedence over the name-resolved sources
+- A missing or malformed file is logged and skipped - it never aborts the run
+- Nested `include:` inside a file template is **not** supported (it logs a warning and is ignored)
+
### Repository URL Configuration
You can override the default repository URLs for TRaSH-Guides and Recyclarr templates:
@@ -583,6 +611,79 @@ Notes:
- clone order will be displayed in `DEBUG` log
- **experimental**, available since `v1.10.0`
+### Reusable Profiles {#profiles}
+
+Experimental
+
+`profiles:` lets a template file stay free of any profile name, and binds it to one in the config.
+That makes the file reusable: the same file can be shared by several instances, each under a
+different name, without editing it.
+
+```yaml title="config.yml"
+radarr:
+ movies:
+ base_url: !secret RADARR_URL
+ api_key: !secret RADARR_API_KEY
+ profiles:
+ - name: UHD
+ includes: ./profiles/radarr/quality.yml
+ - name: HD
+ # The very same file, bound to a second name
+ includes: ./profiles/radarr/quality.yml
+```
+
+```yaml title="profiles/radarr/quality.yml"
+# Note there is no profile name anywhere in this file.
+custom_formats:
+ - trash_ids:
+ - 496f355514737f7d83bf7aa4d24f8169 # TrueHD Atmos
+ assign_scores_to:
+ - score: 5000
+ - trash_ids:
+ - 2f22d89048b01681dde8afe203bf2e95 # DTS X
+ assign_scores_to:
+ - score: 4500
+```
+
+`includes` accepts a single entry, a list, or full include items - so a profile can be built from
+any template source, not just files:
+
+```yaml
+profiles:
+ - name: UHD
+ includes: ./profiles/uhd.yml # a single path
+ - name: HD
+ includes: # a list
+ - ./profiles/hd.yml
+ - radarr-custom-formats-hd-bluray-web # a Recyclarr template
+ - name: Trash
+ includes:
+ - template: ./profiles/trash-profile.json # a full include item
+ source: TRASH
+```
+
+**How the name is bound:**
+
+| In the included template | Result |
+| ------------------------------------------ | ----------------------------------------------------------------- |
+| No quality profile | Only custom format scores are bound to `name` |
+| Exactly one quality profile | It is renamed to `name`, whatever it was called |
+| More than one quality profile | The entry is **skipped** with a warning - use `include:` instead |
+| A custom format with no `assign_scores_to` | An assignment to `name` is created |
+| Any `assign_scores_to` entry | Its name is set to `name`; `score` / `use_default_score` are kept |
+
+Notes:
+
+- Every score assignment is redirected to `name`, including ones that already name a profile. That
+ is what lets an unmodified upstream template be reused under a name of your choosing.
+- Because only one quality profile may be bound per entry, there is never any ambiguity about
+ which profile those scores belong to.
+- `profiles:` is processed **after** `include:` and **before** the instance's own `custom_formats`
+ and `quality_profiles`, so your instance-level config still wins.
+- [`renameQualityProfiles`](#quality-profile-rename) and [`cloneQualityProfiles`](#quality-profile-clone)
+ run afterwards, so a bound profile can still be renamed or cloned.
+- Binding two entries to the same name is allowed - they merge like any other same-named profiles.
+
## Custom Formats Definitions {#custom-format-definitions}
Custom formats can be defined in two ways:
diff --git a/docs/docs/configuration/general.md b/docs/docs/configuration/general.md
index 71e77825..d8efbf3d 100644
--- a/docs/docs/configuration/general.md
+++ b/docs/docs/configuration/general.md
@@ -27,6 +27,8 @@ At the moment we have the following order:
- TRaSH
- Recyclarr templates
- Local Files
+- File templates (`include:` with a path)
+- Profiles (`profiles:`)
- Config file (global level)
- Config file (instance level)
diff --git a/examples/full/config/config.yml b/examples/full/config/config.yml
index 88275b2a..d68173e4 100644
--- a/examples/full/config/config.yml
+++ b/examples/full/config/config.yml
@@ -250,6 +250,12 @@ radarr:
trash_cfgroup_exclude_cfs:
- id: dc98083864ea246d05a42df0d05f81cc # remove x265 (HD)
+ # (experimental) Reusable profiles: the included file carries no profile name, so the same
+ # file can be bound to a different name here and in any other instance.
+ profiles:
+ - name: ExampleBoundProfile
+ includes: ./profiles/radarr-audio.yml
+
# Experimental defaults for all TRaSH profile includes in this instance (since v1.28.0)
trash_cfgroup_config:
include_optional: true
diff --git a/examples/full/config/profiles/radarr-audio.yml b/examples/full/config/profiles/radarr-audio.yml
new file mode 100644
index 00000000..39cc7e7f
--- /dev/null
+++ b/examples/full/config/profiles/radarr-audio.yml
@@ -0,0 +1,39 @@
+# Reusable profile definition (experimental).
+#
+# Note that no profile name appears anywhere in this file. It is supplied by the `profiles:`
+# entry in config.yml that includes it, so the same file can be bound to several names, in
+# several instances, without being edited.
+#
+# Included from config.yml as:
+# profiles:
+# - name: ExampleBoundProfile
+# includes: ./profiles/radarr-audio.yml
+
+quality_profiles:
+ - upgrade:
+ allowed: true
+ until_quality: WEB 2160p
+ until_score: 1000
+ min_format_score: 5
+ min_format_score: 0
+ quality_sort: top
+ qualities:
+ - name: Remux-2160p
+ - name: WEB 2160p
+ qualities:
+ - WEBDL-2160p
+ - WEBRip-2160p
+
+custom_formats:
+ - trash_ids:
+ - 496f355514737f7d83bf7aa4d24f8169 # TrueHD Atmos
+ assign_scores_to:
+ - score: 5000
+ - trash_ids:
+ - 2f22d89048b01681dde8afe203bf2e95 # DTS X
+ assign_scores_to:
+ - score: 4500
+ - trash_ids:
+ - 417804f7f2c4308c1f4c5d380d4c4475 # ATMOS (undefined)
+ assign_scores_to:
+ - score: 3000
diff --git a/src/config.test.ts b/src/config.test.ts
index 55083af9..58f831b3 100644
--- a/src/config.test.ts
+++ b/src/config.test.ts
@@ -1,5 +1,9 @@
+// The named exports of node:fs are mocked below; the default export stays real, which is what the
+// file-template importer uses - so these tests can write actual template files to a temp dir.
+import { default as realFs } from "node:fs";
+import os from "node:os";
import path from "node:path";
-import { beforeEach, describe, expect, test, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import yaml from "yaml";
import {
getSecrets,
@@ -108,6 +112,36 @@ radarr: {}
const transformed = transformConfig(config);
expect(transformed).not.toBeNull();
});
+
+ test("should normalize every profiles[].includes shorthand into include items", async () => {
+ const withProfiles: InputConfigSchema = yaml.parse(`
+radarr:
+ movies:
+ base_url: http://radarr:7878
+ api_key: test
+ profiles:
+ - name: UHD
+ includes: ./profiles/uhd.yml
+ - name: HD
+ includes:
+ - ./profiles/hd.yml
+ - template: ./profiles/trash.json
+ source: TRASH
+`);
+
+ const transformed = transformConfig(withProfiles);
+
+ expect(transformed.radarr!["movies"]!.profiles).toEqual([
+ { name: "UHD", includes: [expect.objectContaining({ template: "./profiles/uhd.yml", source: "RECYCLARR" })] },
+ {
+ name: "HD",
+ includes: [
+ expect.objectContaining({ template: "./profiles/hd.yml", source: "RECYCLARR" }),
+ expect.objectContaining({ template: "./profiles/trash.json", source: "TRASH" }),
+ ],
+ },
+ ]);
+ });
});
describe("mergeConfigsAndTemplates", () => {
@@ -657,6 +691,269 @@ describe("mergeConfigsAndTemplates", () => {
});
});
+describe("file template includes and profiles", () => {
+ let tmpDir: string;
+ let configDir: string;
+
+ const emptyMaps = () => {
+ vi.spyOn(reclarrImporter, "loadRecyclarrTemplates").mockReturnValue(new Map());
+ vi.spyOn(localImporter, "loadLocalRecyclarrTemplate").mockReturnValue(new Map());
+ vi.spyOn(trashGuide, "loadQPFromTrash").mockReturnValue(Promise.resolve(new Map()));
+ vi.spyOn(trashGuide, "loadAllQDsFromTrash").mockReturnValue(Promise.resolve(new Map()));
+ vi.spyOn(trashGuide, "loadTrashCustomFormatGroups").mockReturnValue(Promise.resolve(new Map()));
+ };
+
+ /** Writes a template next to the (mocked) config.yml and returns its config-relative path. */
+ const writeTemplate = (relativePath: string, content: unknown) => {
+ const target = path.join(configDir, relativePath);
+ realFs.mkdirSync(path.dirname(target), { recursive: true });
+ realFs.writeFileSync(target, typeof content === "string" ? content : yaml.stringify(content), "utf8");
+ return `./${relativePath}`;
+ };
+
+ const instance = (overrides: Partial): InputConfigArrInstance => ({
+ custom_formats: [],
+ quality_profiles: [],
+ api_key: "test",
+ base_url: "http://radarr:7878",
+ ...overrides,
+ });
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ tmpDir = realFs.mkdtempSync(path.join(os.tmpdir(), "configarr-config-test-"));
+ configDir = path.join(tmpDir, "config");
+ realFs.mkdirSync(configDir);
+
+ vi.spyOn(env, "getHelpers").mockReturnValue({
+ configLocation: path.join(configDir, "config.yml"),
+ secretLocation: path.join(configDir, "secrets.yml"),
+ repoPath: path.join(tmpDir, "repos"),
+ enableMerge: false,
+ });
+
+ emptyMaps();
+ });
+
+ afterEach(() => {
+ realFs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ test("should resolve a file include relative to the config directory", async () => {
+ const template = writeTemplate("templates/x.yml", {
+ custom_formats: [{ trash_ids: ["cf-file"], assign_scores_to: [{ name: "FileProfile", score: 100 }] }],
+ quality_profiles: [{ ...dummyProfile, name: "FileProfile" }],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template }] }), "RADARR");
+
+ expect(result.config.custom_formats[0]!.trash_ids).toEqual(["cf-file"]);
+ expect(result.config.quality_profiles[0]!.name).toBe("FileProfile");
+ });
+
+ test("should resolve an absolute file include", async () => {
+ writeTemplate("abs.yml", { custom_formats: [{ trash_ids: ["cf-abs"], assign_scores_to: [{ name: "P" }] }] });
+ const absolute = path.join(configDir, "abs.yml");
+
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template: absolute }] }), "RADARR");
+
+ expect(result.config.custom_formats[0]!.trash_ids).toEqual(["cf-abs"]);
+ });
+
+ test("should auto-detect a TRaSH JSON file include without source", async () => {
+ const trashProfile: TrashQP = {
+ trash_id: "file-trash-id",
+ name: "TRASH From File",
+ trash_score_set: "default",
+ upgradeAllowed: true,
+ cutoff: "HDTV-1080p",
+ minFormatScore: 0,
+ cutoffFormatScore: 1000,
+ language: "Any",
+ items: [{ name: "HDTV-1080p", allowed: true }],
+ formatItems: { CF1: "cf-trash-file" },
+ };
+ const template = writeTemplate("trash.json", JSON.stringify(trashProfile));
+
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template }] }), "RADARR");
+
+ expect(result.config.quality_profiles[0]!.name).toBe("TRASH From File");
+ });
+
+ test("should skip a missing file include without failing the merge", async () => {
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template: "./nope.yml" }] }), "RADARR");
+
+ expect(result.config.custom_formats.length).toBe(0);
+ expect(result.config.quality_profiles.length).toBe(0);
+ });
+
+ test("should prefer a known template key over file interpretation", async () => {
+ // A local template whose key happens to look like a filename must keep resolving as before.
+ vi.spyOn(localImporter, "loadLocalRecyclarrTemplate").mockReturnValue(
+ new Map([
+ ["weird.yml", { custom_formats: [{ trash_ids: ["cf-local"], assign_scores_to: [{ name: "P" }] }] }],
+ ]),
+ );
+
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template: "weird.yml" }] }), "RADARR");
+
+ expect(result.config.custom_formats[0]!.trash_ids).toEqual(["cf-local"]);
+ });
+
+ test("should bind a nameless profile file to the configured name (issue #518)", async () => {
+ const template = writeTemplate("uhd.yml", {
+ custom_formats: [
+ { trash_ids: ["cf-atmos"], assign_scores_to: [{ score: 5000 }] },
+ { trash_ids: ["cf-dtsx"], assign_scores_to: [{ score: 4500 }] },
+ ],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.custom_formats).toEqual([
+ { trash_ids: ["cf-atmos"], assign_scores_to: [{ name: "UHD", score: 5000, use_default_score: false }] },
+ { trash_ids: ["cf-dtsx"], assign_scores_to: [{ name: "UHD", score: 4500, use_default_score: false }] },
+ ]);
+ });
+
+ test("should synthesise an assignment for custom formats with no assign_scores_to", async () => {
+ const template = writeTemplate("bare.yml", { custom_formats: [{ trash_ids: ["cf-bare"] }] });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([{ name: "UHD", score: undefined, use_default_score: false }]);
+ });
+
+ test("should rebind an already-named score assignment to the profile name", async () => {
+ const template = writeTemplate("named.yml", {
+ custom_formats: [{ trash_ids: ["cf1"], assign_scores_to: [{ name: "Upstream", score: 10 }] }],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([{ name: "UHD", score: 10, use_default_score: false }]);
+ });
+
+ test("should rename a single included quality profile to the bound name", async () => {
+ const template = writeTemplate("qp.yml", {
+ custom_formats: [{ trash_ids: ["cf1"], assign_scores_to: [{ name: "Foo", score: 1 }] }],
+ quality_profiles: [{ ...dummyProfile, name: "Foo" }],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.quality_profiles.map((p) => p.name)).toEqual(["UHD"]);
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([{ name: "UHD", score: 1, use_default_score: false }]);
+ });
+
+ test("should skip a profile entry resolving to more than one quality profile", async () => {
+ const warnSpy = vi.spyOn(logger, "warn");
+ const template = writeTemplate("two.yml", {
+ quality_profiles: [
+ { ...dummyProfile, name: "A" },
+ { ...dummyProfile, name: "B" },
+ ],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.quality_profiles.length).toBe(0);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("binds exactly one name"));
+ });
+
+ test("should bind the same file to two different names", async () => {
+ const template = writeTemplate("shared.yml", {
+ custom_formats: [{ trash_ids: ["cf-shared"], assign_scores_to: [{ score: 700 }] }],
+ quality_profiles: [dummyProfile],
+ });
+
+ const result = await mergeConfigsAndTemplates(
+ {},
+ instance({
+ profiles: [
+ { name: "UHD", includes: template },
+ { name: "HD", includes: [template] },
+ ],
+ }),
+ "RADARR",
+ );
+
+ expect(result.config.quality_profiles.map((p) => p.name).sort()).toEqual(["HD", "UHD"]);
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([
+ { name: "UHD", score: 700, use_default_score: false },
+ { name: "HD", score: 700, use_default_score: false },
+ ]);
+ });
+
+ test("should accept a recyclarr template name inside a profile entry", async () => {
+ vi.spyOn(reclarrImporter, "loadRecyclarrTemplates").mockReturnValue(
+ new Map([
+ ["some-template", { custom_formats: [{ trash_ids: ["cf-rec"], assign_scores_to: [{ name: "Original", score: 5 }] }] }],
+ ]),
+ );
+
+ const result = await mergeConfigsAndTemplates(
+ {},
+ instance({ profiles: [{ name: "UHD", includes: [{ template: "some-template" }] }] }),
+ "RADARR",
+ );
+
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([{ name: "UHD", score: 5, use_default_score: false }]);
+ });
+
+ test("should still apply renameQualityProfiles after profile binding", async () => {
+ const template = writeTemplate("rename.yml", {
+ custom_formats: [{ trash_ids: ["cf1"], assign_scores_to: [{ score: 3 }] }],
+ quality_profiles: [dummyProfile],
+ });
+
+ const result = await mergeConfigsAndTemplates(
+ {},
+ instance({
+ profiles: [{ name: "UHD", includes: template }],
+ renameQualityProfiles: [{ from: "UHD", to: "UHD2" }],
+ }),
+ "RADARR",
+ );
+
+ expect(result.config.quality_profiles[0]!.name).toBe("UHD2");
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([{ name: "UHD2", score: 3, use_default_score: false }]);
+ });
+
+ test("should not apply a nested include inside a profile file", async () => {
+ writeTemplate("nested-target.yml", { custom_formats: [{ trash_ids: ["cf-nested"], assign_scores_to: [{ name: "N" }] }] });
+ const template = writeTemplate("nested.yml", {
+ include: [{ template: "./nested-target.yml" }],
+ custom_formats: [{ trash_ids: ["cf-outer"], assign_scores_to: [{ score: 1 }] }],
+ });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect(result.config.custom_formats.map((cf) => cf.trash_ids)).toEqual([["cf-outer"]]);
+ });
+
+ test("should not leak `profiles` into the merged instance config", async () => {
+ const template = writeTemplate("leak.yml", { custom_formats: [{ trash_ids: ["cf1"], assign_scores_to: [{ score: 1 }] }] });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ profiles: [{ name: "UHD", includes: template }] }), "RADARR");
+
+ expect("profiles" in result.config).toBe(false);
+ });
+
+ test("should drop a score assignment that has no profile name", async () => {
+ const warnSpy = vi.spyOn(logger, "warn");
+ // Reaches the merge via a plain include, so nothing binds a name to it.
+ const template = writeTemplate("orphan.yml", { custom_formats: [{ trash_ids: ["cf1"], assign_scores_to: [{ score: 1 }] }] });
+
+ const result = await mergeConfigsAndTemplates({}, instance({ include: [{ template }] }), "RADARR");
+
+ // The entry survives but assigns to nothing - crucially it does not invent a profile
+ // literally named "undefined", which is what happened before the guard.
+ expect(result.config.custom_formats[0]!.assign_scores_to).toEqual([]);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("score assignment without a profile name"));
+ });
+});
+
const dummyProfile = {
name: "profile",
min_format_score: 0,
@@ -1787,6 +2084,23 @@ describe("InputConfigSchemaSchema (regression)", () => {
expect(result).toMatchObject({ trash_cfgroup_config: config.trash_cfgroup_config });
});
+ test("does not silently strip an arr instance's profiles, in any includes shorthand", () => {
+ const config = {
+ base_url: "http://radarr:7878",
+ api_key: "key",
+ quality_profiles: [],
+ profiles: [
+ { name: "UHD", includes: "./profiles/uhd.yml" },
+ { name: "HD", includes: ["./profiles/hd.yml", "./profiles/audio.yml"] },
+ { name: "Trash", includes: [{ template: "./profiles/trash.json", source: "TRASH" as const }] },
+ ],
+ };
+
+ const result = InputConfigArrInstanceSchema.parse(config);
+
+ expect(result).toMatchObject({ profiles: config.profiles });
+ });
+
test("does not silently strip an include item's trash_cfgroup_* overrides", () => {
const config = {
template: "some-profile",
diff --git a/src/config.ts b/src/config.ts
index 84b73197..f49aec7f 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -5,6 +5,7 @@ import yaml from "yaml";
import { NamingConfigResource as RadarrNamingConfigResource } from "./__generated__/radarr/data-contracts";
import { NamingConfigResource as SonarrNamingConfigResource } from "./__generated__/sonarr/data-contracts";
import { getHelpers } from "./env";
+import { isFilePath, loadTemplateFromFile } from "./file-template-importer";
import { loadLocalRecyclarrTemplate } from "./local-importer";
import { logger } from "./logger";
import { filterInvalidQualityProfiles } from "./quality-profiles";
@@ -23,11 +24,12 @@ import {
transformTrashQPCFs,
transformTrashQPToTemplate,
} from "./trash-guide";
-import { ArrType, MappedMergedTemplates, MappedTemplates } from "./types/common.types";
+import { ArrType, MappedMergedTemplates, MappedTemplates, TemplateScoreAssignment } from "./types/common.types";
import {
ConfigArrInstance,
ConfigCustomFormat,
ConfigIncludeItem,
+ ConfigProfile,
ConfigQualityProfile,
ConfigSchema,
InputConfigArrInstance,
@@ -36,6 +38,7 @@ import {
InputConfigIncludeItem,
InputConfigInstance,
InputConfigMetadataProfile,
+ InputConfigProfile,
InputConfigRemotePath,
InputConfigSchema,
InputConfigSchemaSchema,
@@ -261,7 +264,12 @@ export const transformConfig = (input: InputConfigSchema): ConfigSchema => {
return { ...rest, assign_scores_to: mapped_assign_scores };
});
- p[key] = { ...value, include: value.include?.map(parseIncludes), custom_formats: mappedCustomFormats };
+ p[key] = {
+ ...value,
+ include: value.include?.map(parseIncludes),
+ profiles: value.profiles?.map(parseProfile),
+ custom_formats: mappedCustomFormats,
+ };
return p;
},
{} as Record,
@@ -288,6 +296,20 @@ export const parseIncludes = (input: InputConfigIncludeItem): ConfigIncludeItem
trash_cfgroup_exclude_cfs: input.trash_cfgroup_exclude_cfs,
});
+/**
+ * Normalize a `profiles[].includes` shorthand into the same shape `include:` uses. Done here
+ * rather than in a Zod `.transform` because validation runs in lenient mode and hands back the
+ * raw, untransformed data on failure - a bare string would then leak downstream.
+ */
+export const parseProfile = (input: InputConfigProfile): ConfigProfile => {
+ const asList = Array.isArray(input.includes) ? input.includes : [input.includes];
+
+ return {
+ name: input.name,
+ includes: asList.map((e) => parseIncludes(typeof e === "string" ? { template: e } : e)),
+ };
+};
+
/**
* Validate remote path mappings configuration
* Checks for duplicate host + remote_path combinations and validates structure
@@ -493,6 +515,18 @@ const applyQualityDefinitionFromInclude = (
logger.info(`QualityDefinition: Applied '${qd.type}' from include (${qualities.length} qualities).`);
};
+/** Everything needed to resolve an `include` list. Shared by `include:` and `profiles:`. */
+type IncludeResolutionContext = {
+ recyclarr: Map;
+ local: Map;
+ trash: Map;
+ trashQD: Map;
+ trashCFGroupMapping: TrashCFGroupMapping;
+ useExcludeSemantics: boolean;
+ cfGroupOptions?: TransformTrashCFGroupsOptions;
+ autoTrashCfGroupDefaults: TransformTrashQPCFGroupsOptions;
+};
+
const includeTemplateOrderDefault = async (
include: InputConfigIncludeItem[],
{
@@ -504,16 +538,7 @@ const includeTemplateOrderDefault = async (
useExcludeSemantics,
cfGroupOptions,
autoTrashCfGroupDefaults,
- }: {
- recyclarr: Map;
- local: Map;
- trash: Map;
- trashQD: Map;
- trashCFGroupMapping: TrashCFGroupMapping;
- useExcludeSemantics: boolean;
- cfGroupOptions?: TransformTrashCFGroupsOptions;
- autoTrashCfGroupDefaults: TransformTrashQPCFGroupsOptions;
- },
+ }: IncludeResolutionContext,
{ mergedTemplates }: { mergedTemplates: MappedMergedTemplates },
) => {
const resolveAutoTrashCfGroupOptions = (includeItem: InputConfigIncludeItem): TransformTrashQPCFGroupsOptions => ({
@@ -529,6 +554,7 @@ const includeTemplateOrderDefault = async (
recyclarr: InputConfigIncludeItem[];
trash: InputConfigIncludeItem[];
url: InputConfigIncludeItem[];
+ file: InputConfigIncludeItem[];
}>(
(previous, current) => {
// Check if template is a URL - all URLs go to url array, source is passed to loader
@@ -537,6 +563,16 @@ const includeTemplateOrderDefault = async (
return previous;
}
+ // Only treat it as a path once it has been ruled out as a known template key, so any
+ // value that resolves today keeps resolving exactly the same way.
+ const isKnownTemplateKey =
+ recyclarr.has(current.template) || local.has(current.template) || trash.has(current.template) || trashQD.has(current.template);
+
+ if (!isKnownTemplateKey && isFilePath(current.template)) {
+ previous.file.push(current);
+ return previous;
+ }
+
switch (current.source) {
case "TRASH":
if (trash.has(current.template) || trashQD.has(current.template)) {
@@ -583,23 +619,24 @@ const includeTemplateOrderDefault = async (
return previous;
},
- { recyclarr: [], trash: [], local: [], url: [] },
+ { recyclarr: [], trash: [], local: [], url: [], file: [] },
);
logger.info(
- `Found ${include.length} templates to include. Mapped to [recyclarr]=${mappedIncludes.recyclarr.length}, [local]=${mappedIncludes.local.length}, [trash]=${mappedIncludes.trash.length}, [url]=${mappedIncludes.url.length} ...`,
+ `Found ${include.length} templates to include. Mapped to [recyclarr]=${mappedIncludes.recyclarr.length}, [local]=${mappedIncludes.local.length}, [trash]=${mappedIncludes.trash.length}, [url]=${mappedIncludes.url.length}, [file]=${mappedIncludes.file.length} ...`,
);
- // Process URL templates
- for (const e of mappedIncludes.url) {
- const resolvedTemplate = await loadTemplateFromUrl(e.template, e.source);
- if (resolvedTemplate == null) {
- logger.warn(`Failed to load template from URL: '${e.template}'`);
- continue;
- }
-
- // Route to appropriate handler based on source
- if (e.source === "TRASH") {
+ /**
+ * Route an already-resolved template to the right merge function. `kind` is passed in rather
+ * than sniffed here so each caller keeps its own detection rules (URL includes stay keyed on
+ * `source` alone; file includes can auto-detect TRaSH by `trash_id`).
+ */
+ const applyResolvedTemplate = (
+ e: InputConfigIncludeItem,
+ resolvedTemplate: MappedTemplates | TrashQP | TrashQualityDefinition,
+ kind: "TRASH" | "RECYCLARR",
+ ) => {
+ if (kind === "TRASH") {
if (isTrashQualityDefinition(resolvedTemplate)) {
applyQualityDefinitionFromInclude(resolvedTemplate, e.preferred_ratio, mergedTemplates);
} else {
@@ -613,6 +650,18 @@ const includeTemplateOrderDefault = async (
} else {
includeRecyclarrTemplate(resolvedTemplate as MappedTemplates, { mergedTemplates, trashCFGroupMapping, cfGroupOptions });
}
+ };
+
+ // Process URL templates
+ for (const e of mappedIncludes.url) {
+ const resolvedTemplate = await loadTemplateFromUrl(e.template, e.source);
+ if (resolvedTemplate == null) {
+ logger.warn(`Failed to load template from URL: '${e.template}'`);
+ continue;
+ }
+
+ // Route to appropriate handler based on source
+ applyResolvedTemplate(e, resolvedTemplate, e.source === "TRASH" ? "TRASH" : "RECYCLARR");
}
mappedIncludes.trash.forEach((e) => {
@@ -651,6 +700,102 @@ const includeTemplateOrderDefault = async (
}
includeRecyclarrTemplate(resolvedTemplate, { mergedTemplates, trashCFGroupMapping, cfGroupOptions });
});
+ // File templates last: an explicit path is the most direct reference a user can write, so it
+ // sits closest to the config and wins over the name-resolved sources (see docs/general.md).
+ mappedIncludes.file.forEach((e) => {
+ const loaded = loadTemplateFromFile(e.template, e.source);
+ if (loaded == null) {
+ // loadTemplateFromFile already logged the concrete reason and resolved path.
+ return;
+ }
+ applyResolvedTemplate(e, loaded.template, loaded.kind);
+ });
+};
+
+/**
+ * Rebind everything a single `profiles[]` entry resolved to onto `name`, so a template file can
+ * stay free of any profile name and be reused under a different one per instance.
+ *
+ * Returns false when the entry must be skipped entirely (already logged).
+ */
+export const bindProfileName = (name: string, scratch: MappedMergedTemplates): boolean => {
+ if (scratch.quality_profiles.length > 1) {
+ logger.warn(
+ `Profile '${name}': includes resolve to ${scratch.quality_profiles.length} quality profiles, but a profile entry binds exactly one name. Ignoring this entry - use a plain 'include:' instead, or split the templates.`,
+ );
+ return false;
+ }
+
+ const [qualityProfile] = scratch.quality_profiles;
+
+ if (qualityProfile == null) {
+ // Legitimate: a profile file may only carry custom formats, with the quality profile itself
+ // managed elsewhere (or already present on the server).
+ logger.debug(`Profile '${name}': includes resolve to no quality profile. Binding custom format scores only.`);
+ } else {
+ if (qualityProfile.name !== name) {
+ logger.info(`Profile '${name}': bound included quality profile '${qualityProfile.name}' -> '${name}'.`);
+ }
+ qualityProfile.name = name;
+ }
+
+ // Every score assignment is redirected to the bound name - including ones that already carry a
+ // name. That is what lets an unmodified upstream template be reused under any name; it is safe
+ // because the check above guarantees this scratch holds at most one quality profile, so there
+ // is no other legitimate target for these scores.
+ scratch.custom_formats.forEach((cf) => {
+ const assignments = cf.assign_scores_to as TemplateScoreAssignment[] | undefined;
+
+ if (assignments == null || assignments.length === 0) {
+ cf.assign_scores_to = [{ name }];
+ return;
+ }
+
+ assignments.forEach((assignment) => {
+ if (assignment.name != null && assignment.name !== name) {
+ logger.debug(`Profile '${name}': rebound custom format score assignment '${assignment.name}' -> '${name}'.`);
+ }
+ assignment.name = name;
+ });
+ });
+
+ return true;
+};
+
+/**
+ * Resolve each `profiles[]` entry in isolation and merge the result under the configured name.
+ *
+ * Resolution reuses `includeTemplateOrderDefault` against a scratch buffer, so a profile entry
+ * accepts every include form (file path, URL, recyclarr/local name, trash id) for free.
+ */
+const includeProfileDefinitions = async (
+ profiles: InputConfigProfile[],
+ ctx: IncludeResolutionContext,
+ { mergedTemplates }: { mergedTemplates: MappedMergedTemplates },
+) => {
+ for (const rawProfile of profiles) {
+ // parseProfile is idempotent, so this is safe whether or not transformConfig already ran.
+ const profile: ConfigProfile = parseProfile(rawProfile);
+
+ if (profile.includes.length === 0) {
+ logger.warn(`Profile '${profile.name}' does not include anything. Ignoring.`);
+ continue;
+ }
+
+ const scratch: MappedMergedTemplates = { custom_formats: [], quality_profiles: [] };
+ await includeTemplateOrderDefault(profile.includes, ctx, { mergedTemplates: scratch });
+
+ if (!bindProfileName(profile.name, scratch)) {
+ continue;
+ }
+
+ // A MappedMergedTemplates is a MappedTemplates, so the ordinary merge applies unchanged.
+ includeRecyclarrTemplate(scratch, {
+ mergedTemplates,
+ trashCFGroupMapping: ctx.trashCFGroupMapping,
+ cfGroupOptions: ctx.cfGroupOptions,
+ });
+ }
};
type MergedScoreInfo = {
@@ -677,6 +822,13 @@ const mergeAndReduceCustomFormats = (cfs: InputConfigCustomFormat[]) => {
const existing = idToQualityProfileToScore.get(id)!;
[...(cf.quality_profiles || []), ...(cf.assign_scores_to || [])].forEach((qp) => {
+ // Template files bypass schema validation, so a nameless assignment can reach here.
+ // Without this it would silently create a quality profile literally named "undefined".
+ if ((qp as TemplateScoreAssignment).name == null) {
+ logger.warn(`Custom format '${id}' has a score assignment without a profile name. Ignoring it.`);
+ return;
+ }
+
const hasUseDefaultScore = qp.use_default_score === true;
if (!existing.has(qp.name)) {
@@ -761,23 +913,25 @@ export const mergeConfigsAndTemplates = async (
const cfGroupOptions: TransformTrashCFGroupsOptions = {
silenceRequiredCfGroupExclusionWarnings: globalConfig.silenceRequiredCfGroupExclusionWarnings === true,
};
+ const includeContext: IncludeResolutionContext = {
+ recyclarr: recyclarrTemplateMap,
+ local: localTemplateMap,
+ trash: trashTemplates,
+ trashQD: trashQDTemplates,
+ trashCFGroupMapping,
+ useExcludeSemantics,
+ cfGroupOptions,
+ autoTrashCfGroupDefaults,
+ };
+
if (instanceConfig.include) {
- await includeTemplateOrderDefault(
- instanceConfig.include,
- {
- recyclarr: recyclarrTemplateMap,
- local: localTemplateMap,
- trash: trashTemplates,
- trashQD: trashQDTemplates,
- trashCFGroupMapping,
- useExcludeSemantics,
- cfGroupOptions,
- autoTrashCfGroupDefaults,
- },
- {
- mergedTemplates,
- },
- );
+ await includeTemplateOrderDefault(instanceConfig.include, includeContext, { mergedTemplates });
+ }
+
+ // After `include` (so a bound profile can build on included templates) but before the instance's
+ // own custom_formats/quality_profiles, which must keep winning over anything template-provided.
+ if (instanceConfig.profiles) {
+ await includeProfileDefinitions(instanceConfig.profiles, includeContext, { mergedTemplates });
}
// Now handle instanceConfig custom_format_groups before direct custom_formats
diff --git a/src/file-template-importer.test.ts b/src/file-template-importer.test.ts
new file mode 100644
index 00000000..327c21ef
--- /dev/null
+++ b/src/file-template-importer.test.ts
@@ -0,0 +1,194 @@
+import { default as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import * as env from "./env";
+import { isFilePath, loadTemplateFromFile, resolveConfigRelativePath } from "./file-template-importer";
+import { MappedTemplates } from "./types/common.types";
+import { TrashQP } from "./types/trashguide.types";
+
+vi.mock("./logger", () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("file-template-importer", () => {
+ let tmpDir: string;
+ let configDir: string;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "configarr-file-template-"));
+ configDir = path.join(tmpDir, "config");
+ fs.mkdirSync(configDir);
+
+ vi.spyOn(env, "getHelpers").mockReturnValue({
+ configLocation: path.join(configDir, "config.yml"),
+ secretLocation: path.join(configDir, "secrets.yml"),
+ repoPath: path.join(tmpDir, "repos"),
+ enableMerge: false,
+ });
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ const writeTemplate = (relativePath: string, content: string) => {
+ const target = path.join(configDir, relativePath);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, content, "utf8");
+ return target;
+ };
+
+ describe("isFilePath", () => {
+ test("should detect paths with separators", () => {
+ expect(isFilePath("./profiles/uhd.yml")).toBe(true);
+ expect(isFilePath("../shared/uhd.yaml")).toBe(true);
+ expect(isFilePath("/data/profiles/uhd.yml")).toBe(true);
+ expect(isFilePath("profiles/uhd")).toBe(true);
+ expect(isFilePath("C:\\profiles\\uhd.yml")).toBe(true);
+ });
+
+ test("should detect bare filenames by extension", () => {
+ expect(isFilePath("uhd.yml")).toBe(true);
+ expect(isFilePath("uhd.yaml")).toBe(true);
+ expect(isFilePath("uhd.json")).toBe(true);
+ expect(isFilePath("uhd.YML")).toBe(true);
+ });
+
+ test("should reject template names and trash ids", () => {
+ // These are the shapes every existing config uses - none may become a file path.
+ expect(isFilePath("sonarr-cf")).toBe(false);
+ expect(isFilePath("radarr-quality-definition-movie")).toBe(false);
+ expect(isFilePath("d1498e7d189fbe6c7110ceaabb7473e6")).toBe(false);
+ expect(isFilePath("")).toBe(false);
+ });
+
+ test("should reject URLs, which have their own include path", () => {
+ expect(isFilePath("https://example.com/template.yml")).toBe(false);
+ expect(isFilePath("http://example.com/a/b.json")).toBe(false);
+ });
+ });
+
+ describe("resolveConfigRelativePath", () => {
+ test("should resolve relative paths against the config directory, not cwd", () => {
+ expect(resolveConfigRelativePath("./profiles/uhd.yml")).toBe(path.join(configDir, "profiles/uhd.yml"));
+ expect(resolveConfigRelativePath("profiles/uhd.yml")).toBe(path.join(configDir, "profiles/uhd.yml"));
+ // The whole point: independent of where the process was started.
+ expect(resolveConfigRelativePath("./profiles/uhd.yml")).not.toBe(path.resolve(process.cwd(), "./profiles/uhd.yml"));
+ });
+
+ test("should pass absolute paths through untouched", () => {
+ expect(resolveConfigRelativePath("/data/profiles/uhd.yml")).toBe("/data/profiles/uhd.yml");
+ });
+ });
+
+ describe("loadTemplateFromFile", () => {
+ test("should load a recyclarr format YAML template", () => {
+ writeTemplate(
+ "profiles/uhd.yml",
+ `custom_formats:
+ - trash_ids:
+ - cf1
+ assign_scores_to:
+ - name: UHD
+ score: 5000
+`,
+ );
+
+ const result = loadTemplateFromFile("./profiles/uhd.yml");
+
+ expect(result?.kind).toBe("RECYCLARR");
+ expect((result?.template as MappedTemplates).custom_formats).toEqual([
+ { trash_ids: ["cf1"], assign_scores_to: [{ name: "UHD", score: 5000 }] },
+ ]);
+ });
+
+ test("should load an absolute path", () => {
+ const absolute = writeTemplate("abs.yml", "custom_formats:\n - trash_ids: [cf1]\n");
+
+ const result = loadTemplateFromFile(absolute);
+
+ expect(result?.kind).toBe("RECYCLARR");
+ });
+
+ test("should map deprecated quality_profiles to assign_scores_to", () => {
+ writeTemplate(
+ "deprecated.yml",
+ `custom_formats:
+ - trash_ids: [cf1]
+ quality_profiles:
+ - name: Old
+ score: 10
+`,
+ );
+
+ const result = loadTemplateFromFile("./deprecated.yml");
+
+ expect((result?.template as MappedTemplates).custom_formats?.[0]!.assign_scores_to).toEqual([{ name: "Old", score: 10 }]);
+ });
+
+ test("should keep a nameless assign_scores_to entry intact for profile binding", () => {
+ writeTemplate(
+ "nameless.yml",
+ `custom_formats:
+ - trash_ids: [cf1]
+ assign_scores_to:
+ - score: 5000
+`,
+ );
+
+ const result = loadTemplateFromFile("./nameless.yml");
+
+ expect((result?.template as MappedTemplates).custom_formats?.[0]!.assign_scores_to).toEqual([{ score: 5000 }]);
+ });
+
+ test("should auto-detect a TRaSH JSON template via trash_id without source", () => {
+ writeTemplate("trash.json", JSON.stringify({ trash_id: "abc", name: "TrashProfile" }));
+
+ const result = loadTemplateFromFile("./trash.json");
+
+ expect(result?.kind).toBe("TRASH");
+ expect((result?.template as TrashQP).name).toBe("TrashProfile");
+ });
+
+ test("should honour an explicit source: TRASH", () => {
+ writeTemplate("explicit.yml", "trash_id: abc\nname: TrashProfile\n");
+
+ expect(loadTemplateFromFile("./explicit.yml", "TRASH")?.kind).toBe("TRASH");
+ });
+
+ test("should return null for a missing file", () => {
+ expect(loadTemplateFromFile("./does-not-exist.yml")).toBeNull();
+ });
+
+ test("should return null for an empty file", () => {
+ writeTemplate("empty.yml", "");
+
+ expect(loadTemplateFromFile("./empty.yml")).toBeNull();
+ });
+
+ test("should return null for a top-level array", () => {
+ writeTemplate("array.yml", "- a\n- b\n");
+
+ expect(loadTemplateFromFile("./array.yml")).toBeNull();
+ });
+
+ test("should return null for malformed YAML", () => {
+ writeTemplate("broken.yml", "custom_formats: [\n unclosed");
+
+ expect(loadTemplateFromFile("./broken.yml")).toBeNull();
+ });
+
+ test("should return null when no recognized template key is present", () => {
+ writeTemplate("unrelated.yml", "some_other_key: true\n");
+
+ expect(loadTemplateFromFile("./unrelated.yml")).toBeNull();
+ });
+ });
+});
diff --git a/src/file-template-importer.ts b/src/file-template-importer.ts
new file mode 100644
index 00000000..dc5556c6
--- /dev/null
+++ b/src/file-template-importer.ts
@@ -0,0 +1,137 @@
+import { default as fs } from "node:fs";
+import path from "node:path";
+import yaml from "yaml";
+import { getHelpers } from "./env";
+import { logger } from "./logger";
+import { MappedTemplates } from "./types/common.types";
+import { TrashQP, TrashQualityDefinition } from "./types/trashguide.types";
+import { isUrl } from "./url-template-importer";
+
+/** Which parser/merge path a loaded file belongs to. */
+export type LoadedFileTemplateKind = "TRASH" | "RECYCLARR";
+
+export type LoadedFileTemplate = {
+ kind: LoadedFileTemplateKind;
+ template: MappedTemplates | TrashQP | TrashQualityDefinition;
+};
+
+/** Keys we recognize in a Recyclarr-format template. Used to reject obviously-wrong files. */
+const KNOWN_TEMPLATE_KEYS = [
+ "quality_definition",
+ "custom_formats",
+ "custom_format_groups",
+ "quality_profiles",
+ "include",
+ "customFormatDefinitions",
+ "media_management",
+ "media_naming",
+ "media_naming_api",
+ "ui_config",
+ "delete_unmanaged_custom_formats",
+ "delete_unmanaged_quality_profiles",
+ "delete_unmanaged_metadata_profiles",
+ "metadata_profiles",
+ "root_folders",
+ "delay_profiles",
+ "download_clients",
+];
+
+/**
+ * Whether an `include`'s `template` value names a file on disk rather than a template name.
+ *
+ * Existing template keys can never look like this: recyclarr and local template maps are keyed by
+ * basename-without-extension (recyclarr-importer.ts / local-importer.ts), and TRaSH keys are
+ * `trash_id` hex strings. Callers additionally check the template maps first, so a name that
+ * already resolves keeps resolving exactly as before.
+ */
+export const isFilePath = (str: string): boolean => {
+ if (!str || isUrl(str)) {
+ return false;
+ }
+
+ return str.includes("/") || str.includes("\\") || /\.(ya?ml|json)$/i.test(str);
+};
+
+/**
+ * Absolute paths are used as-is; relative paths resolve against the directory holding config.yml
+ * (not the process cwd), so a config keeps working regardless of where configarr was started.
+ */
+export const resolveConfigRelativePath = (templatePath: string): string => {
+ if (path.isAbsolute(templatePath)) {
+ return templatePath;
+ }
+
+ return path.resolve(path.dirname(getHelpers().configLocation), templatePath);
+};
+
+/**
+ * Load a template from a local file. Never throws - every failure is logged and returns `null`
+ * so a single bad path cannot abort the whole run (same contract as `loadTemplateFromUrl`).
+ */
+export const loadTemplateFromFile = (templatePath: string, source?: LoadedFileTemplateKind): LoadedFileTemplate | null => {
+ const resolved = resolveConfigRelativePath(templatePath);
+
+ if (!fs.existsSync(resolved)) {
+ logger.error(`Template file '${resolved}' does not exist. Ignoring.`);
+ return null;
+ }
+
+ let parsed: unknown;
+
+ try {
+ logger.debug(`Loading template from file: ${resolved}`);
+ // YAML is a superset of JSON, so this handles .yml, .yaml and .json alike.
+ parsed = yaml.parse(fs.readFileSync(resolved, "utf8"));
+ } catch (error) {
+ logger.error(`Failed to load template file '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
+ return null;
+ }
+
+ if (parsed == null) {
+ logger.warn(`Template file '${resolved}' is empty. Ignoring.`);
+ return null;
+ }
+
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
+ logger.warn(`Template file '${resolved}' must contain a YAML/JSON object. Ignoring.`);
+ return null;
+ }
+
+ const content = parsed as Record;
+
+ // Unlike URL templates, a local TRaSH file does not need `source: TRASH` - a `trash_id`
+ // is unambiguous and only ever appears in TRaSH-Guides quality profiles/definitions.
+ if (source === "TRASH" || typeof content.trash_id === "string") {
+ logger.debug(`Successfully loaded TRASH template from file: ${resolved}`);
+ return { kind: "TRASH", template: content as unknown as TrashQP | TrashQualityDefinition };
+ }
+
+ if (!KNOWN_TEMPLATE_KEYS.some((key) => key in content)) {
+ logger.warn(`Template file '${resolved}' contains no recognized template keys. Ignoring.`);
+ return null;
+ }
+
+ const template = content as MappedTemplates;
+
+ // Changes from Recyclarr 7.2.0: https://github.com/recyclarr/recyclarr/releases/tag/v7.2.0
+ if (template.custom_formats) {
+ template.custom_formats = template.custom_formats.map((cf) => {
+ if (cf.assign_scores_to == null && cf.quality_profiles == null) {
+ // Only `debug`: a CF entry with no assignment is legitimate in a file consumed through a
+ // `profiles:` entry, which supplies the name. Matches transformConfig's handling.
+ logger.debug(`Template file '${resolved}' has no assign_scores_to for CF entry '${cf.trash_ids}'.`);
+ }
+
+ if (cf.quality_profiles) {
+ logger.warn(
+ `Deprecated: (Template file '${resolved}') For custom_formats please rename 'quality_profiles' to 'assign_scores_to'. See recyclarr v7.2.0`,
+ );
+ }
+
+ return { ...cf, assign_scores_to: cf.assign_scores_to ?? cf.quality_profiles ?? [] };
+ });
+ }
+
+ logger.debug(`Successfully loaded template from file: ${resolved}`);
+ return { kind: "RECYCLARR", template };
+};
diff --git a/src/telemetry.test.ts b/src/telemetry.test.ts
index 1d0fc117..4b4916b1 100644
--- a/src/telemetry.test.ts
+++ b/src/telemetry.test.ts
@@ -199,6 +199,7 @@ describe("Telemetry", () => {
recyclarr_templates: true,
trash_guide_templates: true,
local_templates: false,
+ file_templates: false,
local_custom_formats_path: false,
local_config_templates_path: false,
custom_formats: true,
@@ -210,6 +211,7 @@ describe("Telemetry", () => {
quality_definition: false,
rename_quality_profiles: false,
clone_quality_profiles: false,
+ config_profiles: false,
delete_unmanaged_quality_profiles: true,
media_management: true,
media_naming: false,
diff --git a/src/telemetry.ts b/src/telemetry.ts
index 179cce01..b3568e8e 100644
--- a/src/telemetry.ts
+++ b/src/telemetry.ts
@@ -1,4 +1,5 @@
import { getEnvs } from "./env";
+import { isFilePath } from "./file-template-importer";
import { logger } from "./logger";
import { ArrType } from "./types/common.types";
import { InputConfigSchema, InputConfigArrInstance, MergedConfigInstance } from "./types/config.types";
@@ -23,6 +24,7 @@ export interface TelemetryData {
recyclarr_templates: boolean;
trash_guide_templates: boolean;
local_templates: boolean;
+ file_templates: boolean;
// Local paths usage
local_custom_formats_path: boolean;
@@ -40,6 +42,7 @@ export interface TelemetryData {
quality_definition: boolean;
rename_quality_profiles: boolean;
clone_quality_profiles: boolean;
+ config_profiles: boolean;
delete_unmanaged_quality_profiles: boolean;
// Media management features
@@ -255,11 +258,14 @@ export class Telemetry {
let recyclarrTemplateCount = 0;
let trashGuideTemplateCount = 0;
let localTemplateCount = 0;
+ let fileTemplateCount = 0;
for (const instance of allInstances) {
if (instance.include) {
for (const include of instance.include) {
- if (include.source === "RECYCLARR") {
+ if (isFilePath(include.template)) {
+ fileTemplateCount++;
+ } else if (include.source === "RECYCLARR") {
recyclarrTemplateCount++;
} else if (include.source === "TRASH") {
trashGuideTemplateCount++;
@@ -297,6 +303,7 @@ export class Telemetry {
recyclarr_templates: recyclarrTemplateCount > 0,
trash_guide_templates: trashGuideTemplateCount > 0,
local_templates: localTemplateCount > 0,
+ file_templates: fileTemplateCount > 0,
// Local paths usage
local_custom_formats_path: globalConfig.localCustomFormatsPath !== undefined,
@@ -312,6 +319,7 @@ export class Telemetry {
quality_definition: allInstances.some((i) => i.quality_definition !== undefined),
rename_quality_profiles: allInstances.some((i) => i.renameQualityProfiles && i.renameQualityProfiles.length > 0),
clone_quality_profiles: allInstances.some((i) => i.cloneQualityProfiles && i.cloneQualityProfiles.length > 0),
+ config_profiles: allInstances.some((i) => i.profiles && i.profiles.length > 0),
delete_unmanaged_quality_profiles: allInstances.some((i) => i.delete_unmanaged_quality_profiles?.enabled),
media_management: allInstances.some((i) => i.media_management !== undefined),
diff --git a/src/types/common.types.ts b/src/types/common.types.ts
index c914872c..b8cecc28 100644
--- a/src/types/common.types.ts
+++ b/src/types/common.types.ts
@@ -100,6 +100,18 @@ export type MappedTemplates = Partial<
export type MappedMergedTemplates = MappedTemplates & Required>;
+/**
+ * A score assignment as it may actually appear inside a template file. Template files bypass
+ * Zod entirely (they're `yaml.parse(...) as MappedTemplates`), so `name` can be missing - which
+ * is legitimate when the template is consumed through a `profiles:` entry that supplies it.
+ * `bindProfileName` is the single narrowing boundary: everything downstream sees `name: string`.
+ */
+export type TemplateScoreAssignment = {
+ name?: string;
+ score?: number;
+ use_default_score?: boolean;
+};
+
export const ArrTypeConst = ["RADARR", "SONARR", "WHISPARR", "READARR", "LIDARR"] as const;
export type ArrType = (typeof ArrTypeConst)[number];
diff --git a/src/types/config.types.ts b/src/types/config.types.ts
index 98a8c1d2..fad0c5bf 100644
--- a/src/types/config.types.ts
+++ b/src/types/config.types.ts
@@ -24,6 +24,7 @@ const ScoreAssignmentSchema = z.object({
score: z.number().optional(),
use_default_score: z.boolean().optional(),
});
+export type ScoreAssignment = z.infer;
export const InputConfigIncludeItemSchema = z.object({
// depends on source what this actually is. Can be the filename -> recyclarr or id in the files -> trash
@@ -47,6 +48,16 @@ export const InputConfigIncludeItemSchema = z.object({
});
export type InputConfigIncludeItem = z.infer;
+// @experimental - A reusable profile: load template(s) and bind every unnamed quality profile
+// inside them to `name`. Lets one file be shared across instances under different names.
+export const InputConfigProfileSchema = z.object({
+ name: z.string(),
+ // A single path/URL/template name, or a list mixing plain strings and full include
+ // items (the latter for `source: TRASH`, `preferred_ratio`, `trash_cfgroup_*`, ...).
+ includes: z.union([z.string(), z.array(z.union([z.string(), InputConfigIncludeItemSchema]))]),
+});
+export type InputConfigProfile = z.infer;
+
export const InputConfigQualityProfileItemSchema = z.object({
name: z.string(),
qualities: z.array(z.string()).optional(),
@@ -316,6 +327,8 @@ export const InputConfigArrInstanceSchema = z.object({
})
.optional(),
include: z.array(InputConfigIncludeItemSchema).optional(),
+ // @experimental - Reusable, name-independent profiles. Processed after `include`.
+ profiles: z.array(InputConfigProfileSchema).optional(),
// @experimental since v1.12.0 (expanded cf-group semantics since v1.28.0)
custom_format_groups: z.array(InputConfigCustomFormatGroupSchema).optional(),
// @experimental @since v1.28.0 - Instance-level defaults for TRaSH auto CF-group loading.
@@ -404,8 +417,9 @@ export type ConfigCustomFormat = Pick & Pi
export type ConfigCustomFormatList = Pick;
-export type ConfigArrInstance = OmitTyped & {
+export type ConfigArrInstance = OmitTyped & {
include?: ConfigIncludeItem[];
+ profiles?: ConfigProfile[];
custom_formats: ConfigCustomFormat[];
quality_profiles: ConfigQualityProfile[];
metadata_profiles?: InputConfigMetadataProfile[];
@@ -425,5 +439,12 @@ export type ConfigIncludeItem = OmitTyped & {
source: InputConfigIncludeItem["source"];
};
+/** A `profiles[]` entry with its shorthand `includes` normalized to a list of include items. */
+export type ConfigProfile = OmitTyped & {
+ includes: ConfigIncludeItem[];
+};
+
export type InputConfigInstance = OmitTyped;
-export type MergedConfigInstance = OmitTyped;
+// `include`/`profiles` are fully resolved during merging - omitting them here makes any
+// downstream read of them a compile error instead of a silently ignored field.
+export type MergedConfigInstance = OmitTyped;