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;