├── docs
├── static
│ ├── .nojekyll
│ └── img
│ │ └── favicon.ico
├── docs
│ ├── support
│ │ ├── _category_.json
│ │ └── need-help.mdx
│ ├── configuration
│ │ ├── _category_.json
│ │ ├── index.mdx
│ │ ├── secrets.mdx
│ │ ├── env_vars.mdx
│ │ ├── config_examples.mdx
│ │ └── schema-validation.mdx
│ ├── getting-started
│ │ ├── _category_.json
│ │ └── installation.mdx
│ └── index.mdx
├── babel.config.js
├── tsconfig.json
├── .gitignore
├── sidebars.ts
├── README.md
├── package.json
└── src
│ └── css
│ └── custom.css
├── .github
├── CODEOWNERS
├── PULL_REQUEST_TEMPLATE.md
├── workflows
│ ├── labeler.yml
│ ├── test-docs.yml
│ ├── stale.yaml
│ ├── deploy-docs.yml
│ ├── release.yml
│ └── development.yml
├── ISSUE_TEMPLATE
│ ├── config.yaml
│ └── feature-request.yaml
├── labeler.yml
├── release.yml
└── dependabot.yml
├── .gitattributes
├── global.json
├── .nuke
└── parameters.json
├── src
├── API
│ └── src
│ │ ├── appsettings.json
│ │ ├── Pipeline
│ │ ├── Queues
│ │ │ ├── BaseUnboundedTaskQueue.cs
│ │ │ ├── BaseBoundedTaskQueue.cs
│ │ │ ├── ITaskQueue.cs
│ │ │ ├── BaseTaskQueue.cs
│ │ │ └── BaseUniqueTaskQueue.cs
│ │ └── BasePeriodicService.cs
│ │ ├── Services
│ │ ├── StartupInformationService.cs.cs
│ │ ├── ProviderPingService.cs
│ │ ├── RadarrMovieService.cs
│ │ └── SonarrSeriesService.cs
│ │ ├── Fetcharr.API.csproj
│ │ └── Program.cs
├── Provider.Plex
│ └── src
│ │ ├── Models
│ │ ├── MediaResponse.cs
│ │ ├── GraphQL
│ │ │ └── PaginatedResult.cs
│ │ ├── Friends
│ │ │ ├── PlexFriendListResponseType.cs
│ │ │ ├── PlexFriendUserContainer.cs
│ │ │ ├── PlexFriendUser.cs
│ │ │ └── PlexUserWatchlistResponseType.cs
│ │ ├── Metadata
│ │ │ ├── PlexMetadataGuid.cs
│ │ │ ├── PlexMetadataGenre.cs
│ │ │ └── PlexMetadataItem.cs
│ │ ├── MediaContainer.cs
│ │ └── Watchlist
│ │ │ ├── WatchlistMetadataItemType.cs
│ │ │ └── WatchlistMetadataItem.cs
│ │ ├── Extensions
│ │ └── IServiceCollectionExtensions.cs
│ │ ├── Fetcharr.Provider.Plex.csproj
│ │ ├── PlexFriendsWatchlistClient.cs
│ │ ├── PlexClient.cs
│ │ └── PlexMetadataClient.cs
├── Testing
│ ├── Containers
│ │ ├── src
│ │ │ ├── Usings.cs
│ │ │ ├── Radarr
│ │ │ │ ├── RadarrContainer.cs
│ │ │ │ └── RadarrConfiguration.cs
│ │ │ ├── Sonarr
│ │ │ │ └── SonarrContainer.cs
│ │ │ ├── Extensions
│ │ │ │ └── ArgumentInfoExtensions.cs
│ │ │ └── Fetcharr.Testing.Containers.csproj
│ │ └── test
│ │ │ ├── Fixtures
│ │ │ ├── RadarrIntegrationFixture.cs
│ │ │ └── SonarrIntegrationFixture.cs
│ │ │ ├── Fetcharr.Testing.Containers.Tests.csproj
│ │ │ └── Integration
│ │ │ ├── RadarrIntegrationTests.cs
│ │ │ └── SonarrIntegrationTests.cs
│ ├── Assertions
│ │ ├── src
│ │ │ ├── Extensions
│ │ │ │ ├── RadarrClientExtensions.cs
│ │ │ │ └── SonarrClientExtensions.cs
│ │ │ └── Fetcharr.Testing.Assertions.csproj
│ │ └── test
│ │ │ └── Fetcharr.Testing.Assertions.Tests.csproj
│ └── Layers
│ │ └── src
│ │ ├── BaseServiceTestingLayer.cs
│ │ ├── RadarrIntegrationLayer.cs
│ │ ├── SonarrIntegrationLayer.cs
│ │ ├── Fetcharr.Testing.Layers.csproj
│ │ ├── SonarrTestingLayer.cs
│ │ └── RadarrTestingLayer.cs
├── Models
│ ├── src
│ │ ├── Configuration
│ │ │ ├── ServiceFillterAllowType.cs
│ │ │ ├── ConfigurationInclude.cs
│ │ │ ├── Sonarr
│ │ │ │ ├── SonarrSeriesType.cs
│ │ │ │ ├── SonarrMonitoredItems.cs
│ │ │ │ └── FetcharrSonarrConfiguration.cs
│ │ │ ├── Radarr
│ │ │ │ ├── RadarrMonitoredItems.cs
│ │ │ │ ├── FetcharrRadarrConfiguration.cs
│ │ │ │ └── RadarrMovieStatus.cs
│ │ │ ├── ServiceFilterCollection.cs
│ │ │ ├── Plex
│ │ │ │ └── FetcharrPlexConfiguration.cs
│ │ │ └── FetcharrConfiguration.cs
│ │ ├── Extensions
│ │ │ ├── IEnumerableExtensions.cs
│ │ │ └── IServiceCollectionExtensions.cs
│ │ ├── Fetcharr.Models.csproj
│ │ └── Environment.cs
│ └── test
│ │ └── Fetcharr.Models.Tests.csproj
├── Configuration
│ └── src
│ │ ├── Exceptions
│ │ └── DuplicateServiceKeyException.cs
│ │ ├── Validation
│ │ ├── IValidationRule.cs
│ │ ├── Rules
│ │ │ ├── PlexTokenValidationRule.cs
│ │ │ └── ServiceValidationRule.cs
│ │ ├── ValidationResult.cs
│ │ ├── Extensions
│ │ │ └── IServiceCollectionExtensions.cs
│ │ └── ValidationPipeline.cs
│ │ ├── Secrets
│ │ ├── Extensions
│ │ │ └── DeserializerBuilderExtensions.cs
│ │ ├── Exceptions
│ │ │ └── SecretValueNotFoundException.cs
│ │ ├── SecretsDeserializer.cs
│ │ └── SecretsProvider.cs
│ │ ├── EnvironmentVariables
│ │ ├── Extensions
│ │ │ └── DeserializerBuilderExtensions.cs
│ │ ├── Exceptions
│ │ │ └── EnvironmentVariableNotFoundException.cs
│ │ └── EnvironmentVariableDeserializer.cs
│ │ ├── Fetcharr.Configuration.csproj
│ │ ├── Extensions
│ │ └── IServiceCollectionExtensions.cs
│ │ └── Parsing
│ │ ├── ConfigurationLocator.cs
│ │ ├── ConfigurationMerger.cs
│ │ └── ServiceInstanceNodeDeserializer.cs
├── Cache
│ ├── InMemory
│ │ └── src
│ │ │ ├── Models
│ │ │ └── CacheEvictionEventArgs.cs
│ │ │ ├── Fetcharr.Cache.InMemory.csproj
│ │ │ ├── InMemoryCachingProviderOptions.cs
│ │ │ └── Extensions
│ │ │ └── CachingProviderOptionsExtensions.cs
│ ├── SQLite
│ │ └── src
│ │ │ ├── SQLiteCachingProviderOptions.cs
│ │ │ ├── Contexts
│ │ │ └── CacheContext.cs
│ │ │ ├── Fetcharr.Cache.SQLite.csproj
│ │ │ ├── Migrations
│ │ │ ├── 20240724145919_InitialMigration.cs
│ │ │ ├── CacheContextModelSnapshot.cs
│ │ │ └── 20240724145919_InitialMigration.Designer.cs
│ │ │ ├── Models
│ │ │ └── CacheItem.cs
│ │ │ └── Extensions
│ │ │ └── CachingProviderOptionsExtensions.cs
│ ├── Hybrid
│ │ └── src
│ │ │ ├── Fetcharr.Cache.Hybrid.csproj
│ │ │ ├── HybridCachingProviderOptions.cs
│ │ │ └── Extensions
│ │ │ └── CachingProviderOptionsExtensions.cs
│ └── Core
│ │ └── src
│ │ ├── CachingProviderOptions.cs
│ │ ├── Logging
│ │ └── CacheProviderLogging.cs
│ │ ├── Fetcharr.Cache.Core.csproj
│ │ ├── Services
│ │ ├── CacheInitializationService.cs
│ │ └── CacheEvictionService.cs
│ │ ├── CacheValue.cs
│ │ ├── BaseCachingProviderOptions.cs
│ │ └── Extensions
│ │ └── IServiceCollectionExtensions.cs
├── Directory.Build.props
├── Provider.Radarr
│ ├── test
│ │ └── Fetcharr.Provider.Radarr.Tests.csproj
│ └── src
│ │ ├── Models
│ │ ├── RadarrRootFolder.cs
│ │ ├── RadarrQualityProfile.cs
│ │ ├── RadarrMovie.cs
│ │ └── RadarrMovieOptions.cs
│ │ ├── Extensions
│ │ └── IServiceCollectionExtensions.cs
│ │ └── Fetcharr.Provider.Radarr.csproj
├── Provider.Sonarr
│ ├── test
│ │ └── Fetcharr.Provider.Sonarr.Tests.csproj
│ └── src
│ │ ├── Models
│ │ ├── SonarrRootFolder.cs
│ │ ├── SonarrQualityProfile.cs
│ │ ├── SonarrSeriesStatus.cs
│ │ ├── SonarrSeriesStaticstics.cs
│ │ ├── SonarrSeries.cs
│ │ └── SonarrSeriesOptions.cs
│ │ ├── Extensions
│ │ └── IServiceCollectionExtensions.cs
│ │ └── Fetcharr.Provider.Sonarr.csproj
├── Provider
│ └── src
│ │ ├── Fetcharr.Provider.csproj
│ │ ├── ExternalProvider.cs
│ │ └── Exceptions
│ │ └── ExternalProviderUnreachableException.cs
├── Shared
│ └── src
│ │ ├── Http
│ │ ├── FlurlErrorLogger.cs
│ │ └── Extensions
│ │ │ └── IServiceCollectionExtensions.cs
│ │ ├── Fetcharr.Shared.csproj
│ │ └── GraphQL
│ │ ├── GraphQLResponseExtensions.cs
│ │ └── GraphQLHttpClientExtensions.cs
├── FetcharrCore.slnf
├── Fetcharr.slnf
└── Directory.Packages.props
├── .gitpod.Dockerfile
├── cSpell.json
├── .vscode
├── extensions.json
├── launch.json
├── tasks.json
└── settings.json
├── .devcontainer
├── docker-compose.yml
├── Dockerfile
└── devcontainer.json
├── .build
├── Build.Clean.cs
├── Directory.Build.props
├── Directory.Build.targets
├── .editorconfig
├── Build.Format.cs
├── Build.Compile.cs
├── Build.Test.cs
├── _build.csproj
├── Configuration.cs
├── Build.Publish.cs
├── Build.cs
├── Build.Environment.cs
└── Build.Release.cs
├── docker-compose.yml
├── .dockerignore
├── Dockerfile
├── .gitpod.yml
└── LICENSE
/docs/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @maxnatamo
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | *.cs text=auto eol=lf diff=csharp
3 | *.sln text=auto eol=crlf
--------------------------------------------------------------------------------
/docs/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fetcharr/fetcharr/HEAD/docs/static/img/favicon.ico
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "8.0.100",
4 | "rollForward": "latestMinor"
5 | }
6 | }
--------------------------------------------------------------------------------
/.nuke/parameters.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./build.schema.json",
3 | "Solution": "src/Fetcharr.sln"
4 | }
5 |
--------------------------------------------------------------------------------
/docs/docs/support/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Support",
3 | "position": 4,
4 | "collapsed": false
5 | }
6 |
--------------------------------------------------------------------------------
/docs/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
3 | };
4 |
--------------------------------------------------------------------------------
/docs/docs/configuration/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Configuration",
3 | "position": 3,
4 | "collapsed": false
5 | }
6 |
--------------------------------------------------------------------------------
/docs/docs/getting-started/_category_.json:
--------------------------------------------------------------------------------
1 | {
2 | "label": "Getting Started",
3 | "position": 2,
4 | "collapsed": false
5 | }
6 |
--------------------------------------------------------------------------------
/src/API/src/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Kestrel": {
3 | "Endpoints": {
4 | "Http": {
5 | "Url": "http://0.0.0.0:8080"
6 | }
7 | }
8 | }
9 | }
--------------------------------------------------------------------------------
/.gitpod.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM gitpod/workspace-dotnet:2024-07-14-17-19-51
2 |
3 | RUN dotnet tool install Nuke.GlobalTool --global 2>&1
4 |
5 | ENV PATH="/home/gitpod/.dotnet/tools:${PATH}"
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // This file is not used in compilation. It is here just for a nice editor experience.
3 | "extends": "@docusaurus/tsconfig",
4 | "compilerOptions": {
5 | "baseUrl": "."
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/MediaResponse.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Plex.Models
2 | {
3 | public class MediaResponse
4 | {
5 | public MediaContainer MediaContainer { get; set; } = new();
6 | }
7 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Usings.cs:
--------------------------------------------------------------------------------
1 | global using Docker.DotNet.Models;
2 |
3 | global using DotNet.Testcontainers;
4 | global using DotNet.Testcontainers.Builders;
5 | global using DotNet.Testcontainers.Configurations;
6 | global using DotNet.Testcontainers.Containers;
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | #### Overview
2 |
3 | Write a clear and concise description of the PR.
4 |
5 | If the PR fixes an issue, define it here:
6 | - Fixes #XXXX
7 |
8 | #### PR Checklist
9 |
10 | - [ ] Successful Docker build (`docker buildx build .`)
--------------------------------------------------------------------------------
/src/Models/src/Configuration/ServiceFillterAllowType.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models.Configuration
2 | {
3 | public enum ServiceFilterAllowType
4 | {
5 | ExplicitlyAllowed,
6 |
7 | ImplicitlyAllowed,
8 |
9 | NotAllowed,
10 | }
11 | }
--------------------------------------------------------------------------------
/cSpell.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
3 | "version": "0.2",
4 | "language": "en",
5 | "words": [],
6 | "ignoreWords": [],
7 | "ignoreRegExpList": [
8 | "\\((.*)\\)"
9 | ]
10 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Exceptions/DuplicateServiceKeyException.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Configuration.Exceptions
2 | {
3 | public class DuplicateServiceKeyException(string name, string service)
4 | : Exception($"Duplicate {service} name in configuration: '{name}'")
5 | {
6 | }
7 | }
--------------------------------------------------------------------------------
/src/Cache/InMemory/src/Models/CacheEvictionEventArgs.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Cache.InMemory.Models
2 | {
3 | public class CacheEvictionEventArgs(string key, object? value) : EventArgs
4 | {
5 | public readonly string Key = key;
6 |
7 | public readonly object? Value = value;
8 | }
9 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/GraphQL/PaginatedResult.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | public class PaginatedResult
6 | {
7 | [JsonPropertyName("nodes")]
8 | public List Nodes { get; set; } = [];
9 | }
10 | }
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "EditorConfig.editorconfig",
4 | "ms-dotnettools.csdevkit",
5 | "ms-dotnettools.vscodeintellicode-csharp",
6 | "redhat.vscode-yaml",
7 | "esbenp.prettier-vscode",
8 | "bradlc.vscode-tailwindcss"
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: Dockerfile
8 |
9 | volumes:
10 | - ../..:/workspaces:cached
11 |
12 | # Overrides default command so things don't shut down after the process ends.
13 | command: sleep infinite
--------------------------------------------------------------------------------
/.github/workflows/labeler.yml:
--------------------------------------------------------------------------------
1 | name: "Label PR"
2 |
3 | on:
4 | - pull_request_target
5 |
6 | jobs:
7 | labeler:
8 | permissions:
9 | contents: read
10 | pull-requests: write
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | - uses: actions/labeler@v5
15 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
2 |
3 | blank_issues_enabled: false
4 | contact_links:
5 | - name: 💬 Support via Discussions
6 | url: https://github.com/fetcharr/fetcharr/discussions
7 | about: Ask questions and discuss with other members who use Fetcharr.
--------------------------------------------------------------------------------
/.build/Build.Clean.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.Tools.DotNet;
3 |
4 | partial class Build : NukeBuild
5 | {
6 | Target Clean => _ => _
7 | .Description("Cleans the build tree.\n")
8 | .Before(Restore)
9 | .Executes(() =>
10 | DotNetTasks.DotNetClean(c => c.SetProject(SolutionFilePath)));
11 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Friends/PlexFriendListResponseType.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | public class PlexFriendListResponseType
6 | {
7 | [JsonPropertyName("allFriendsV2")]
8 | public List Friends { get; set; } = [];
9 | }
10 | }
--------------------------------------------------------------------------------
/src/Models/test/Fetcharr.Models.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Models
5 | Fetcharr.Models.Tests
6 | Fetcharr.Models.Tests
7 | Test
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/Testing/Assertions/src/Extensions/RadarrClientExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Provider.Radarr;
2 |
3 | namespace Fetcharr.Testing.Assertions.Extensions
4 | {
5 | public static partial class RadarrClientExtensions
6 | {
7 | public static RadarrClientAssertions Should(this RadarrClient instance)
8 | => new(instance);
9 | }
10 | }
--------------------------------------------------------------------------------
/src/Testing/Assertions/src/Extensions/SonarrClientExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Provider.Sonarr;
2 |
3 | namespace Fetcharr.Testing.Assertions.Extensions
4 | {
5 | public static partial class SonarrClientExtensions
6 | {
7 | public static SonarrClientAssertions Should(this SonarrClient instance)
8 | => new(instance);
9 | }
10 | }
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | fetcharr:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | container_name: fetcharr
7 | ports:
8 | - 8080:8080
9 | volumes:
10 | - type: bind
11 | source: ./fetcharr.yaml
12 | target: /config/fetcharr.yaml
13 | read_only: true
14 | restart: unless-stopped
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/.gitkeep
2 | **/.vscode
3 | **/.build
4 | **/.nuke
5 | .dockerignore
6 | .editorconfig
7 | .github
8 | .gitignore
9 | **/config*.yaml
10 | /assets/
11 | docs
12 | LICENSE
13 |
14 | # directories
15 | **/bin/
16 | **/obj/
17 | **/out/
18 |
19 | # files
20 | Dockerfile*
21 | docker-compose.yml
22 | **/*.trx
23 | **/*.md
24 | **/*.ps1
25 | **/*.cmd
26 | **/*.sh
27 |
28 | # gitversion
29 | .git/gitversion_cache
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Metadata/PlexMetadataGuid.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Representation of a single GUID within a Plex metadata item.
7 | ///
8 | public class PlexMetadataGuid
9 | {
10 | [JsonPropertyName("id")]
11 | public string Id { get; set; } = string.Empty;
12 | }
13 | }
--------------------------------------------------------------------------------
/src/Models/src/Extensions/IEnumerableExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models.Extensions
2 | {
3 | public static class IEnumerableExtensions
4 | {
5 | public static bool ContainsAny(
6 | this IEnumerable source,
7 | IEnumerable value,
8 | IEqualityComparer? comparer)
9 | => value.Any(v => source.Contains(v, comparer));
10 | }
11 | }
--------------------------------------------------------------------------------
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/pull-request-labeler.json
2 |
3 | docs:
4 | - changed-files:
5 | - any-glob-to-any-file: 'docs/*'
6 |
7 | enhancement:
8 | - head-branch: ['^feat\(', '^feat:']
9 |
10 | bug-fix:
11 | - head-branch: ['^fix\(', '^fix:']
12 |
13 | build:
14 | - changed-files:
15 | - any-glob-to-any-file:
16 | - .build/*
17 | - .github/workflows/*
--------------------------------------------------------------------------------
/src/Models/src/Fetcharr.Models.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Models
5 | Fetcharr.Models
6 | Fetcharr.Models
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Friends/PlexFriendUserContainer.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Representation of a friend user account container.
7 | ///
8 | public class PlexFriendUserContainer
9 | {
10 | [JsonPropertyName("user")]
11 | public PlexFriendUser User { get; set; } = new();
12 | }
13 | }
--------------------------------------------------------------------------------
/.build/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.build/Directory.Build.targets:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/devcontainers/dotnet:1-8.0-bookworm
2 |
3 | # [Optional] Uncomment this section to install additional OS packages.
4 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
5 | # && apt-get -y install --no-install-recommends
6 |
7 | RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install --lts && nvm use --lts && npm install -g typescript" 2>&1
8 | RUN su vscode -c "dotnet tool install Nuke.GlobalTool --global" 2>&1
--------------------------------------------------------------------------------
/src/Cache/InMemory/src/Fetcharr.Cache.InMemory.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Cache.InMemory
5 | Fetcharr.Cache.InMemory
6 | Fetcharr.Cache.InMemory
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-release-config.json
2 |
3 | changelog:
4 | exclude:
5 | labels:
6 | - ignore-for-release
7 | categories:
8 | - title: 🔨 Breaking changes
9 | labels:
10 | - breaking-change
11 | - title: 🥳 New features
12 | labels:
13 | - enhancement
14 | - title: 🐜 Bug fixes
15 | labels:
16 | - bug-fixes
17 | - title: Other changes
18 | labels:
19 | - "*"
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Friends/PlexFriendUser.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Representation of a friend user account.
7 | ///
8 | public class PlexFriendUser
9 | {
10 | [JsonPropertyName("id")]
11 | public string Id { get; set; } = string.Empty;
12 |
13 | [JsonPropertyName("username")]
14 | public string Username { get; set; } = string.Empty;
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Testing/Assertions/test/Fetcharr.Testing.Assertions.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Testing.Assertions
5 | Fetcharr.Testing.Assertions.Tests
6 | Fetcharr.Testing.Assertions.Tests
7 | Test
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for more information:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 | # https://containers.dev/guide/dependabot
6 |
7 | version: 2
8 | updates:
9 | - package-ecosystem: "devcontainers"
10 | directory: "/"
11 | schedule:
12 | interval: weekly
13 |
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/IValidationRule.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | namespace Fetcharr.Configuration.Validation
4 | {
5 | ///
6 | /// Representation of a validation rule for configuration files.
7 | ///
8 | public interface IValidationRule
9 | {
10 | ///
11 | /// Validates the given configuration file and returns the result.
12 | ///
13 | ValidationResult Validate(FetcharrConfiguration configuration);
14 | }
15 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/test/Fetcharr.Provider.Radarr.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider.Radarr
5 | Fetcharr.Provider.Radarr.Tests
6 | Fetcharr.Provider.Radarr.Tests
7 | Test
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/Provider.Sonarr/test/Fetcharr.Provider.Sonarr.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider.Sonarr
5 | Fetcharr.Provider.Sonarr.Tests
6 | Fetcharr.Provider.Sonarr.Tests
7 | Test
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/Models/src/Configuration/ConfigurationInclude.cs:
--------------------------------------------------------------------------------
1 | using YamlDotNet.Serialization;
2 |
3 | namespace Fetcharr.Models.Configuration
4 | {
5 | ///
6 | /// Represents an inclusion of another configuration file.
7 | ///
8 | public sealed class ConfigurationInclude
9 | {
10 | ///
11 | /// Gets or sets the path of the YAML configuration to include.
12 | ///
13 | [YamlMember(Alias = "config")]
14 | public string? Config { get; set; }
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Friends/PlexUserWatchlistResponseType.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | public class PlexUserWatchlistResponseType
6 | {
7 | [JsonPropertyName("user")]
8 | public PlexWatchlistResponseType User { get; set; } = new();
9 | }
10 |
11 | public class PlexWatchlistResponseType
12 | {
13 | [JsonPropertyName("watchlist")]
14 | public PaginatedResult Watchlist { get; set; } = new();
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/test/Fixtures/RadarrIntegrationFixture.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Layers;
2 |
3 | namespace Fetcharr.Testing.Containers.Tests.Integration
4 | {
5 | [CollectionDefinition(nameof(RadarrIntegrationLayer))]
6 | public class RadarrIntegrationLayerCollection : ICollectionFixture
7 | {
8 | // This class has no code, and is never created. Its purpose is simply
9 | // to be the place to apply [CollectionDefinition] and all the
10 | // ICollectionFixture<> interfaces.
11 | }
12 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/test/Fixtures/SonarrIntegrationFixture.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Layers;
2 |
3 | namespace Fetcharr.Testing.Containers.Tests.Integration
4 | {
5 | [CollectionDefinition(nameof(SonarrIntegrationLayer))]
6 | public class SonarrIntegrationLayerCollection : ICollectionFixture
7 | {
8 | // This class has no code, and is never created. Its purpose is simply
9 | // to be the place to apply [CollectionDefinition] and all the
10 | // ICollectionFixture<> interfaces.
11 | }
12 | }
--------------------------------------------------------------------------------
/src/Provider/src/Fetcharr.Provider.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider
5 | Fetcharr.Provider
6 | Fetcharr.Provider
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/.build/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.cs]
2 | dotnet_style_qualification_for_field = false:warning
3 | dotnet_style_qualification_for_property = false:warning
4 | dotnet_style_qualification_for_method = false:warning
5 | dotnet_style_qualification_for_event = false:warning
6 | dotnet_style_require_accessibility_modifiers = never:warning
7 |
8 | csharp_style_expression_bodied_methods = true:silent
9 | csharp_style_expression_bodied_properties = true:warning
10 | csharp_style_expression_bodied_indexers = true:warning
11 | csharp_style_expression_bodied_accessors = true:warning
12 |
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/SQLiteCachingProviderOptions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 |
3 | namespace Fetcharr.Cache.SQLite
4 | {
5 | ///
6 | /// Options for the SQLite caching provider, .
7 | ///
8 | public class SQLiteCachingProviderOptions(string name) : BaseCachingProviderOptions(name)
9 | {
10 | ///
11 | /// Gets or sets the path for the SQLite database file.
12 | ///
13 | public string DatabasePath { get; set; } = $"{name}.sqlite";
14 | }
15 | }
--------------------------------------------------------------------------------
/.build/Build.Format.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.Tools.DotNet;
3 |
4 | using static Nuke.Common.Tools.DotNet.DotNetTasks;
5 |
6 | partial class Build : NukeBuild
7 | {
8 | Target Format => _ => _
9 | .Description("Performs linting on the build tree.\n")
10 | .DependsOn(Restore)
11 | .Executes(() =>
12 | DotNetFormat(c => c
13 | .SetProject(SolutionFilePath)
14 | .SetNoRestore(true)
15 | .SetSeverity(DotNetFormatSeverity.error)
16 | .SetVerifyNoChanges(true)));
17 | }
--------------------------------------------------------------------------------
/src/Cache/Hybrid/src/Fetcharr.Cache.Hybrid.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Cache.Hybrid
5 | Fetcharr.Cache.Hybrid
6 | Fetcharr.Cache.Hybrid
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/Rules/PlexTokenValidationRule.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | namespace Fetcharr.Configuration.Validation.Rules
4 | {
5 | public class PlexTokenValidationRule : IValidationRule
6 | {
7 | public ValidationResult Validate(FetcharrConfiguration configuration)
8 | {
9 | if(string.IsNullOrEmpty(configuration.Plex.ApiToken))
10 | {
11 | return new ValidationResult("`plex.api_token` must be set.");
12 | }
13 |
14 | return ValidationResult.Success;
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/MediaContainer.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Plex.Models
2 | {
3 | public class MediaContainer
4 | {
5 | public string Identifier { get; set; } = string.Empty;
6 |
7 | public string LibrarySectionID { get; set; } = string.Empty;
8 |
9 | public string LibrarySectionTitle { get; set; } = string.Empty;
10 |
11 | public int Size { get; set; }
12 |
13 | public int Offset { get; set; }
14 |
15 | public int TotalSize { get; set; }
16 |
17 | public IEnumerable Metadata { get; set; } = [];
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Models/RadarrRootFolder.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Radarr.Models
2 | {
3 | ///
4 | /// Representation of a Radarr root folder.
5 | ///
6 | public class RadarrRootFolder
7 | {
8 | ///
9 | /// Gets or sets the unique ID of the root folder.
10 | ///
11 | public int Id { get; set; }
12 |
13 | ///
14 | /// Gets or sets the absolute path of the root folder.
15 | ///
16 | public string Path { get; set; } = string.Empty;
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrRootFolder.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Sonarr.Models
2 | {
3 | ///
4 | /// Representation of a Sonarr root folder.
5 | ///
6 | public class SonarrRootFolder
7 | {
8 | ///
9 | /// Gets or sets the unique ID of the root folder.
10 | ///
11 | public int Id { get; set; }
12 |
13 | ///
14 | /// Gets or sets the absolute path of the root folder.
15 | ///
16 | public string Path { get; set; } = string.Empty;
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Models/RadarrQualityProfile.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Radarr.Models
2 | {
3 | ///
4 | /// Representation of a Radarr quality profile.
5 | ///
6 | public class RadarrQualityProfile
7 | {
8 | ///
9 | /// Gets or sets the unique ID of the quality profile.
10 | ///
11 | public int Id { get; set; }
12 |
13 | ///
14 | /// Gets or sets the name of the quality profile
15 | ///
16 | public string Name { get; set; } = string.Empty;
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrQualityProfile.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Sonarr.Models
2 | {
3 | ///
4 | /// Representation of a Sonarr quality profile.
5 | ///
6 | public class SonarrQualityProfile
7 | {
8 | ///
9 | /// Gets or sets the unique ID of the quality profile.
10 | ///
11 | public int Id { get; set; }
12 |
13 | ///
14 | /// Gets or sets the name of the quality profile
15 | ///
16 | public string Name { get; set; } = string.Empty;
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Shared/src/Http/FlurlErrorLogger.cs:
--------------------------------------------------------------------------------
1 | using Flurl.Http;
2 |
3 | using Microsoft.Extensions.Logging;
4 |
5 | namespace Fetcharr.Shared.Http
6 | {
7 | public interface IFlurlErrorLogger : IFlurlEventHandler
8 | {
9 |
10 | }
11 |
12 | public class FlurlErrorLogger(ILogger logger)
13 | : FlurlEventHandler
14 | , IFlurlErrorLogger
15 | {
16 | public override async Task HandleAsync(FlurlEventType eventType, FlurlCall call)
17 | {
18 | logger.LogError("Failed API call to Sonarr: {Error}", await call.Response.GetStringAsync());
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/Queues/BaseUnboundedTaskQueue.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Channels;
2 |
3 | namespace Fetcharr.API.Pipeline.Queues
4 | {
5 | ///
6 | /// Base class for a unbounded task queue, i.e. a task queue with any capacity limit.
7 | ///
8 | /// Type of item to store in the queue.
9 | public abstract class BaseUnboundedTaskQueue
10 | : BaseTaskQueue
11 | {
12 | protected override Channel Queue { get; init; } =
13 | Channel.CreateUnbounded(new UnboundedChannelOptions());
14 | }
15 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Watchlist/WatchlistMetadataItemType.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Type of a Plex watchlist item.
7 | ///
8 | [JsonConverter(typeof(JsonStringEnumConverter))]
9 | public enum WatchlistMetadataItemType
10 | {
11 | ///
12 | /// The watchlist item is a TV show.
13 | ///
14 | Show,
15 |
16 | ///
17 | /// The watchlist item is a movie.
18 | ///
19 | Movie,
20 | }
21 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 |
3 | namespace Fetcharr.Provider.Radarr.Extensions
4 | {
5 | public static class IServieCollectionExtensions
6 | {
7 | ///
8 | /// Registers Radarr services onto the given .
9 | ///
10 | public static IServiceCollection AddRadarrClient(this IServiceCollection services)
11 | {
12 | services.AddSingleton();
13 |
14 | return services;
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 |
3 | namespace Fetcharr.Provider.Sonarr.Extensions
4 | {
5 | public static class IServieCollectionExtensions
6 | {
7 | ///
8 | /// Registers Sonarr services onto the given .
9 | ///
10 | public static IServiceCollection AddSonarrClient(this IServiceCollection services)
11 | {
12 | services.AddSingleton();
13 |
14 | return services;
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/Shared/src/Fetcharr.Shared.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Shared
5 | Fetcharr.Shared
6 | Fetcharr.Shared
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Sonarr/SonarrSeriesType.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models.Configuration.Sonarr
2 | {
3 | ///
4 | /// Enumeration of all available Sonarr series types.
5 | ///
6 | public enum SonarrSeriesType
7 | {
8 | ///
9 | /// Standard item numbering (S01E05)
10 | ///
11 | Standard,
12 |
13 | ///
14 | /// Date (2020-05-25)
15 | ///
16 | Daily,
17 |
18 | ///
19 | /// Absolute episode number (005)
20 | ///
21 | Anime,
22 | }
23 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Secrets/Extensions/DeserializerBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 |
3 | using YamlDotNet.Serialization;
4 |
5 | namespace Fetcharr.Configuration.Secrets.Extensions
6 | {
7 | public static partial class DeserializerBuilderExtensions
8 | {
9 | public static DeserializerBuilder WithSecrets(
10 | this DeserializerBuilder builder,
11 | IServiceProvider provider) => builder
12 | .WithNodeDeserializer(ActivatorUtilities.CreateInstance(provider))
13 | .WithTagMapping("!secret", typeof(SecretsValue));
14 | }
15 | }
--------------------------------------------------------------------------------
/src/Cache/InMemory/src/InMemoryCachingProviderOptions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 |
3 | namespace Fetcharr.Cache.InMemory
4 | {
5 | ///
6 | /// Options for the in-memory caching provider, .
7 | ///
8 | public class InMemoryCachingProviderOptions(string name) : BaseCachingProviderOptions(name)
9 | {
10 | ///
11 | /// Gets or sets the size limit of the in-memory cache.
12 | /// If above 0, limits the in-memory cache to elements.
13 | ///
14 | public long SizeLimit { get; set; } = 1024;
15 | }
16 | }
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Radarr/RadarrMonitoredItems.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models.Configuration.Radarr
2 | {
3 | ///
4 | /// Enumeration of all available monitoring states for movies in Radarr.
5 | ///
6 | public enum RadarrMonitoredItems
7 | {
8 | ///
9 | /// No monitoring.
10 | ///
11 | None,
12 |
13 | ///
14 | /// Monitor only the movie.
15 | ///
16 | MovieOnly,
17 |
18 | ///
19 | /// Monitor the entire movie collection.
20 | ///
21 | MovieAndCollection,
22 | }
23 | }
--------------------------------------------------------------------------------
/src/Cache/Core/src/CachingProviderOptions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 |
3 | namespace Fetcharr.Cache.Core
4 | {
5 | public class CachingProviderOptions(IServiceCollection services)
6 | {
7 | ///
8 | /// Gets or sets the time between cache evictions, for keys that have expired.
9 | ///
10 | public TimeSpan EvictionPeriod { get; set; } = TimeSpan.FromMinutes(1);
11 |
12 | ///
13 | /// Gets the -instance for registering services.
14 | ///
15 | protected internal readonly IServiceCollection Services = services;
16 | }
17 | }
--------------------------------------------------------------------------------
/.build/Build.Compile.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.Tools.DotNet;
3 |
4 | partial class Build : NukeBuild
5 | {
6 | Target Restore => _ => _
7 | .Description("Downloads and install .NET packages.\n")
8 | .Executes(() =>
9 | DotNetTasks.DotNetRestore(c => c.SetProjectFile(SolutionFilePath)));
10 |
11 | Target Compile => _ => _
12 | .Description("Compiles the entire build tree.\n")
13 | .DependsOn(Restore)
14 | .Executes(() =>
15 | DotNetTasks.DotNetBuild(c => c
16 | .SetProjectFile(SolutionFilePath)
17 | .SetNoRestore(true)
18 | .SetConfiguration(Configuration)));
19 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Secrets/Exceptions/SecretValueNotFoundException.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Configuration.EnvironmentVariables.Exceptions
2 | {
3 | public class SecretValueNotFoundException : Exception
4 | {
5 | private const string ExceptionFormat = "Secret value '{0}' was referenced, but was not defined.";
6 |
7 | public SecretValueNotFoundException(string variable)
8 | : base(string.Format(ExceptionFormat, variable))
9 | {
10 |
11 | }
12 |
13 | public SecretValueNotFoundException(string variable, Exception innerException)
14 | : base(string.Format(ExceptionFormat, variable), innerException)
15 | {
16 |
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Configuration/src/EnvironmentVariables/Extensions/DeserializerBuilderExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 |
3 | using YamlDotNet.Serialization;
4 |
5 | namespace Fetcharr.Configuration.EnvironmentVariables.Extensions
6 | {
7 | public static partial class DeserializerBuilderExtensions
8 | {
9 | public static DeserializerBuilder WithEnvironmentVariables(
10 | this DeserializerBuilder builder,
11 | IServiceProvider provider) => builder
12 | .WithNodeDeserializer(ActivatorUtilities.CreateInstance(provider))
13 | .WithTagMapping("!env_var", typeof(EnvironmentVariableValue));
14 | }
15 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Fetcharr.Configuration.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Configuration
5 | Fetcharr.Configuration
6 | Fetcharr.Configuration
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/Testing/Containers/test/Fetcharr.Testing.Containers.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Testing.Containers
5 | Fetcharr.Testing.Containers.Tests
6 | Fetcharr.Testing.Containers.Tests
7 | Test
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.build/Build.Test.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.Tools.DotNet;
3 |
4 | partial class Build : NukeBuild
5 | {
6 | [Parameter("Whether to include integration tests (default: false).")]
7 | readonly bool IncludeIntegrationTests = false;
8 |
9 | Target Test => _ => _
10 | .Description("Runs test suites within the build tree.\n")
11 | .DependsOn(Compile)
12 | .Executes(() =>
13 | DotNetTasks.DotNetTest(c => c
14 | .SetProjectFile(SolutionFilePath)
15 | .SetNoRestore(true)
16 | .SetFilter(this.IncludeIntegrationTests ? "Test" : "Category!=IntegrationTest")
17 | .SetConfiguration(Configuration.Debug)));
18 | }
--------------------------------------------------------------------------------
/src/Testing/Assertions/src/Fetcharr.Testing.Assertions.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Testing.Assertions
5 | Fetcharr.Testing.Assertions
6 | Fetcharr.Testing.Assertions
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/FetcharrCore.slnf:
--------------------------------------------------------------------------------
1 | {
2 | "solution": {
3 | "path": "Fetcharr.sln",
4 | "projects": [
5 | "..\\.build\\_build.csproj",
6 | "API\\src\\Fetcharr.API.csproj",
7 | "Cache\\Core\\src\\Fetcharr.Cache.Core.csproj",
8 | "Cache\\Hybrid\\src\\Fetcharr.Cache.Hybrid.csproj",
9 | "Cache\\InMemory\\src\\Fetcharr.Cache.InMemory.csproj",
10 | "Cache\\SQLite\\src\\Fetcharr.Cache.SQLite.csproj",
11 | "Models\\src\\Fetcharr.Models.csproj",
12 | "Provider\\src\\Fetcharr.Provider.csproj",
13 | "Provider.Plex\\src\\Fetcharr.Provider.Plex.csproj",
14 | "Provider.Radarr\\src\\Fetcharr.Provider.Radarr.csproj",
15 | "Provider.Sonarr\\src\\Fetcharr.Provider.Sonarr.csproj"
16 | ]
17 | }
18 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/test/Integration/RadarrIntegrationTests.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Layers;
2 |
3 | using Flurl.Http;
4 |
5 | namespace Fetcharr.Testing.Containers.Tests.Integration
6 | {
7 | [IntegrationTest]
8 | [Collection(nameof(RadarrIntegrationLayer))]
9 | public class RadarrIntegrationTests(
10 | RadarrIntegrationLayer layer)
11 | {
12 | [Fact]
13 | public async Task AssertRadarrInstanceHealthy()
14 | {
15 | // Arrange
16 |
17 | // Act
18 | IFlurlResponse response = await layer.RadarrApiClient.Request("/api/v3/health").GetAsync();
19 |
20 | // Assert
21 | response.StatusCode.Should().Be(200);
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/test/Integration/SonarrIntegrationTests.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Layers;
2 |
3 | using Flurl.Http;
4 |
5 | namespace Fetcharr.Testing.Containers.Tests.Integration
6 | {
7 | [IntegrationTest]
8 | [Collection(nameof(SonarrIntegrationLayer))]
9 | public class SonarrIntegrationTests(
10 | SonarrIntegrationLayer layer)
11 | {
12 | [Fact]
13 | public async Task AssertSonarrInstanceHealthy()
14 | {
15 | // Arrange
16 |
17 | // Act
18 | IFlurlResponse response = await layer.SonarrApiClient.Request("/api/v3/health").GetAsync();
19 |
20 | // Assert
21 | response.StatusCode.Should().Be(200);
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/src/Models/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | namespace Fetcharr.Models.Extensions
6 | {
7 | public static partial class IServiceCollectionExtensions
8 | {
9 | ///
10 | /// Add the default -implementation to the given -instance.
11 | ///
12 | public static IServiceCollection AddDefaultEnvironment(this IServiceCollection services) =>
13 | services
14 | .AddScoped()
15 | .AddSingleton();
16 | }
17 | }
--------------------------------------------------------------------------------
/src/Cache/Core/src/Logging/CacheProviderLogging.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Logging;
2 |
3 | namespace Fetcharr.Cache.Core.Logging
4 | {
5 | public static partial class CacheProviderLogging
6 | {
7 | [LoggerMessage(
8 | EventId = 0,
9 | Level = LogLevel.Information,
10 | Message = "Cache miss: cache={Cache}, key={Key}")]
11 | public static partial void CacheMiss(this ILogger logger, string cache, string key);
12 |
13 | [LoggerMessage(
14 | EventId = 1,
15 | Level = LogLevel.Information,
16 | Message = "Cache hit: cache={Cache}, key={Key}")]
17 | public static partial void CacheHit(this ILogger logger, string cache, string key);
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Fetcharr.Provider.Radarr.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider.Radarr
5 | Fetcharr.Provider.Radarr
6 | Fetcharr.Provider.Radarr
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Fetcharr.Provider.Sonarr.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider.Sonarr
5 | Fetcharr.Provider.Sonarr
6 | Fetcharr.Provider.Sonarr
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/Provider/src/ExternalProvider.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Provider.Exceptions;
2 |
3 | namespace Fetcharr.Provider
4 | {
5 | ///
6 | /// Base class for external API providers.
7 | ///
8 | public abstract class ExternalProvider
9 | {
10 | ///
11 | /// Gets the name of the provider.
12 | ///
13 | public abstract string ProviderName { get; }
14 |
15 | ///
16 | /// Pings the provider to verify the connection.
17 | ///
18 | /// Thrown if the ping failed.
19 | public abstract Task PingAsync(CancellationToken cancellationToken = default);
20 | }
21 | }
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrSeriesStatus.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Sonarr.Models
4 | {
5 | ///
6 | /// Enumeration of available Sonarr series statuses.
7 | ///
8 | [JsonConverter(typeof(JsonStringEnumConverter))]
9 | public enum SonarrSeriesStatus
10 | {
11 | ///
12 | /// The series has been partly been released, with more to come.
13 | ///
14 | Continuing,
15 |
16 | ///
17 | /// The series has ended.
18 | ///
19 | Ended,
20 |
21 | ///
22 | /// The series has not yet made it's debut.
23 | ///
24 | Upcoming,
25 | }
26 | }
--------------------------------------------------------------------------------
/.build/_build.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net8.0
6 |
7 | CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006
8 | ..
9 | ..
10 | 1
11 | false
12 | true
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/Configuration/src/EnvironmentVariables/Exceptions/EnvironmentVariableNotFoundException.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Configuration.EnvironmentVariables.Exceptions
2 | {
3 | public class EnvironmentVariableNotFoundException : Exception
4 | {
5 | private const string ExceptionFormat = "Environment variable '{0}' was referenced, but was not defined and has no default value.";
6 |
7 | public EnvironmentVariableNotFoundException(string variable)
8 | : base(string.Format(ExceptionFormat, variable))
9 | {
10 |
11 | }
12 |
13 | public EnvironmentVariableNotFoundException(string variable, Exception innerException)
14 | : base(string.Format(ExceptionFormat, variable), innerException)
15 | {
16 |
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/src/Shared/src/Http/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Flurl.Http;
2 | using Flurl.Http.Configuration;
3 |
4 | using Microsoft.Extensions.DependencyInjection;
5 |
6 | namespace Fetcharr.Shared.Http.Extensions
7 | {
8 | public static class IServieCollectionExtensions
9 | {
10 | public static IServiceCollection AddFlurlErrorHandler(this IServiceCollection services)
11 | {
12 | services.AddSingleton();
13 |
14 | services.AddSingleton(sp => new FlurlClientCache()
15 | .WithDefaults(builder =>
16 | builder.EventHandlers.Add((FlurlEventType.OnError, sp.GetService()))));
17 |
18 | return services;
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Contexts/CacheContext.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.SQLite.Models;
2 | using Fetcharr.Models.Configuration;
3 |
4 | using Microsoft.EntityFrameworkCore;
5 | using Microsoft.Extensions.Options;
6 |
7 | namespace Fetcharr.Cache.SQLite.Contexts
8 | {
9 | public class CacheContext(
10 | IOptions options,
11 | IAppDataSetup appDataSetup)
12 | : DbContext
13 | {
14 | public DbSet Items { get; set; }
15 |
16 | protected override void OnConfiguring(DbContextOptionsBuilder builder)
17 | {
18 | string absoluteDatabasePath = Path.Combine(appDataSetup.CacheDirectory, options.Value.DatabasePath);
19 | builder.UseSqlite($"Data Source={absoluteDatabasePath}");
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Models/src/Configuration/ServiceFilterCollection.cs:
--------------------------------------------------------------------------------
1 | using YamlDotNet.Serialization;
2 |
3 | namespace Fetcharr.Models.Configuration
4 | {
5 | ///
6 | /// Collection of all service filters, which can be applied to service instances.
7 | ///
8 | public class ServiceFilterCollection
9 | {
10 | ///
11 | /// Gets or sets the filters for the item genre.
12 | ///
13 | [YamlMember(Alias = "genre")]
14 | public ServiceFilter Genre { get; set; } = [];
15 |
16 | ///
17 | /// Gets or sets the filters for the item certification.
18 | ///
19 | [YamlMember(Alias = "certification")]
20 | public ServiceFilter Certification { get; set; } = [];
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Provider/src/Exceptions/ExternalProviderUnreachableException.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Exceptions
2 | {
3 | [System.Serializable]
4 | public class ExternalProviderUnreachableException
5 | : Exception
6 | {
7 | private const string MessageFormat = "Provider cannot be reached: {0}";
8 |
9 | public readonly string ProviderName;
10 |
11 | public ExternalProviderUnreachableException(string providerName)
12 | : base(string.Format(MessageFormat, providerName))
13 | => this.ProviderName = providerName;
14 |
15 | public ExternalProviderUnreachableException(string providerName, Exception inner)
16 | : base(string.Format(MessageFormat, providerName), inner)
17 | => this.ProviderName = providerName;
18 | }
19 | }
--------------------------------------------------------------------------------
/.build/Configuration.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel;
2 |
3 | using Nuke.Common.Tooling;
4 |
5 | [TypeConverter(typeof(TypeConverter))]
6 | public class Configuration : Enumeration
7 | {
8 | public static Configuration Debug = new() { Value = nameof(Debug) };
9 | public static Configuration Release = new() { Value = nameof(Release) };
10 |
11 | ///
12 | /// Gets whether the current configuration is set to Debug.
13 | ///
14 | public bool IsDebug => this.Equals(Debug);
15 |
16 | ///
17 | /// Gets whether the current configuration is set to Release.
18 | ///
19 | public bool IsRelease => this.Equals(Release);
20 |
21 | public static implicit operator string(Configuration configuration)
22 | => configuration.Value;
23 | }
--------------------------------------------------------------------------------
/src/Cache/Core/src/Fetcharr.Cache.Core.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Cache.Core
5 | Fetcharr.Cache.Core
6 | Fetcharr.Cache.Core
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrSeriesStaticstics.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Sonarr.Models
2 | {
3 | ///
4 | /// Statistics for a Sonarr series.
5 | ///
6 | public class SonarrSeriesStatistics
7 | {
8 | ///
9 | /// Gets or sets the season count of a Sonarr series.
10 | ///
11 | public int SeasonCount { get; set; }
12 |
13 | ///
14 | /// Gets or sets the episode count of a Sonarr series, which are monitored.
15 | ///
16 | public int EpisodeCount { get; set; }
17 |
18 | ///
19 | /// Gets or sets the total episode count of a Sonarr series, including unmonitored episodes.
20 | ///
21 | public int TotalEpisodeCount { get; set; }
22 | }
23 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1.9-labs
2 |
3 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build-env
4 | WORKDIR /app
5 | EXPOSE 5000
6 |
7 | ARG TARGETARCH
8 |
9 | COPY --parents ./src/*.props ./src/*.targets ./src/**/*.csproj ./
10 | RUN dotnet restore src/API/src/Fetcharr.API.csproj -a $TARGETARCH
11 |
12 | COPY . .
13 | RUN dotnet publish src/API/src/Fetcharr.API.csproj -a $TARGETARCH --no-restore -o /out
14 |
15 | FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
16 | WORKDIR /app
17 |
18 | ENV FETCHARR_BASE_DIR="/app/fetcharr" \
19 | FETCHARR_CONFIG_DIR="/config"
20 |
21 | RUN set -ex; \
22 | mkdir -p /config && chown $APP_UID /config;
23 |
24 | COPY --from=build-env --chown=$APP_UID /out /app/fetcharr
25 |
26 | USER $APP_UID
27 | VOLUME /config
28 |
29 | ENTRYPOINT ["dotnet", "/app/fetcharr/Fetcharr.API.dll"]
--------------------------------------------------------------------------------
/.github/workflows/test-docs.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2 |
3 | name: Test docs
4 |
5 | on:
6 | pull_request:
7 | branches:
8 | - develop
9 | paths:
10 | - 'docs/**'
11 | workflow_dispatch:
12 |
13 | defaults:
14 | run:
15 | working-directory: ./docs/
16 |
17 | jobs:
18 | build:
19 | name: Build Docusaurus
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Checkout
23 | uses: actions/checkout@v4
24 | with:
25 | fetch-depth: 0
26 |
27 | - uses: actions/setup-node@v4
28 | with:
29 | node-version: lts/*
30 | cache: npm
31 | cache-dependency-path: docs/package-lock.json
32 |
33 | - name: Install dependencies
34 | run: npm ci
35 |
36 | - name: Build website
37 | run: npm run build
38 |
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Provider.Plex.Clients;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | namespace Fetcharr.Provider.Plex.Extensions
6 | {
7 | public static class IServieCollectionExtensions
8 | {
9 | ///
10 | /// Registers Plex services onto the given .
11 | ///
12 | public static IServiceCollection AddPlexClient(this IServiceCollection services)
13 | {
14 | services.AddSingleton();
15 | services.AddSingleton();
16 | services.AddSingleton();
17 | services.AddSingleton();
18 |
19 | services.AddSingleton();
20 |
21 | return services;
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Radarr/RadarrContainer.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Testing.Containers.Radarr
2 | {
3 | ///
4 | ///
5 | /// Initializes a new instance of the class.
6 | ///
7 | /// The container configuration.
8 | public sealed class RadarrContainer(
9 | RadarrConfiguration configuration)
10 | : DockerContainer(configuration)
11 | {
12 | public readonly RadarrConfiguration Configuration = configuration;
13 |
14 | ///
15 | /// Gets the base URL for the Radarr endpoint.
16 | ///
17 | public string EndpointBase => string.Format(
18 | "http://{0}:{1}",
19 | this.Hostname,
20 | this.GetMappedPublicPort(RadarrBuilder.RadarrPort));
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Sonarr/SonarrContainer.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Testing.Containers.Sonarr
2 | {
3 | ///
4 | ///
5 | /// Initializes a new instance of the class.
6 | ///
7 | /// The container configuration.
8 | public sealed class SonarrContainer(
9 | SonarrConfiguration configuration)
10 | : DockerContainer(configuration)
11 | {
12 | public readonly SonarrConfiguration Configuration = configuration;
13 |
14 | ///
15 | /// Gets the base URL for the Sonarr endpoint.
16 | ///
17 | public string EndpointBase => string.Format(
18 | "http://{0}:{1}",
19 | this.Hostname,
20 | this.GetMappedPublicPort(SonarrBuilder.SonarrPort));
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Shared/src/GraphQL/GraphQLResponseExtensions.cs:
--------------------------------------------------------------------------------
1 | using GraphQL;
2 |
3 | namespace Fetcharr.Shared.GraphQL
4 | {
5 | public static class GraphQLResponseExtensions
6 | {
7 | ///
8 | /// If any errors are present on the response, throw an with all errors.
9 | ///
10 | /// -instance to check for errors on.
11 | public static void ThrowIfErrors(this GraphQLResponse response, string? message = null)
12 | {
13 | if(response.Errors is { Length: > 0 })
14 | {
15 | throw new AggregateException(
16 | message ?? "Error(s) received from GraphQL endpoint.",
17 | response.Errors.Select(error => new Exception(error.Message)));
18 | }
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Sonarr/SonarrMonitoredItems.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models.Configuration.Sonarr
2 | {
3 | ///
4 | /// Enumeration of all possible monitoring states for series in Sonarr.
5 | ///
6 | public enum SonarrMonitoredItems
7 | {
8 | ///
9 | /// No monitoring.
10 | ///
11 | None,
12 |
13 | ///
14 | /// Monitor all seasons.
15 | ///
16 | All,
17 |
18 | ///
19 | /// Monitor only the first season.
20 | ///
21 | FirstSeason,
22 |
23 | ///
24 | /// Monitor all season if series is short. Otherwise, monitor first season.
25 | ///
26 | ///
27 | OnlyShortSeries
28 | }
29 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Provider.Plex
5 | Fetcharr.Provider.Plex
6 | Fetcharr.Provider.Plex
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/docs/sidebars.ts:
--------------------------------------------------------------------------------
1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
2 |
3 | /**
4 | * Creating a sidebar enables you to:
5 | - create an ordered group of docs
6 | - render a sidebar for each doc of that group
7 | - provide next/previous navigation
8 |
9 | The sidebars can be generated from the filesystem, or explicitly defined here.
10 |
11 | Create as many sidebars as you want.
12 | */
13 | const sidebars: SidebarsConfig = {
14 | // By default, Docusaurus generates a sidebar from the docs folder structure
15 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
16 |
17 | // But you can create a sidebar manually
18 | /*
19 | tutorialSidebar: [
20 | 'intro',
21 | 'hello',
22 | {
23 | type: 'category',
24 | label: 'Tutorial',
25 | items: ['tutorial-basics/create-a-document'],
26 | },
27 | ],
28 | */
29 | };
30 |
31 | export default sidebars;
32 |
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Fetcharr.Cache.SQLite.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Cache.SQLite
5 | Fetcharr.Cache.SQLite
6 | Fetcharr.Cache.SQLite
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | runtime; build; native; contentfiles; analyzers; buildtransitive
19 | all
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Plex/FetcharrPlexConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel.DataAnnotations;
2 |
3 | using YamlDotNet.Serialization;
4 |
5 | namespace Fetcharr.Models.Configuration.Plex
6 | {
7 | ///
8 | /// Representation of a Plex configuration.
9 | ///
10 | public sealed class FetcharrPlexConfiguration
11 | {
12 | ///
13 | /// Gets or sets the Plex API token for querying the Plex API.
14 | ///
15 | [Required]
16 | [YamlMember(Alias = "api_token")]
17 | public string ApiToken { get; set; } = string.Empty;
18 |
19 | ///
20 | /// Gets or sets whether to include friends' watchlists in the sync.
21 | ///
22 | [Required]
23 | [YamlMember(Alias = "sync_friends_watchlist")]
24 | public bool IncludeFriendsWatchlist { get; set; } = false;
25 | }
26 | }
--------------------------------------------------------------------------------
/docs/docs/support/need-help.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: need-help
3 | sidebar_position: 1
4 | ---
5 |
6 | # Need help?
7 |
8 | If there's something we can help you with, please reach out on our GitHub repository. However, please make sure you've tried the following:
9 | - Update Fetcharr to the latest version.
10 | - Stopping and restarting Fetcharr.
11 | - Glean over the logs to see if anything sticks out (see ).
12 | - Search the [documentation](/index.mdx), [installation guide](/getting-started/installation) and [issue tracker](https://github.com/Fetcharr/Fetcharr/issues).
13 |
14 | If you still need help, you can reach out on GitHub on either the [Issue Tracker](https://github.com/Fetcharr/Fetcharr/issues) or [Discussions](https://github.com/Fetcharr/Fetcharr/discussions). Please include relevant log messages, if needed.
15 |
16 | ## Where can I find my logs?
17 |
18 | Currently, logs are only written to console output. Still will be updated in the future.
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Website
2 |
3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
4 |
5 | ### Installation
6 |
7 | ```
8 | $ yarn
9 | ```
10 |
11 | ### Local Development
12 |
13 | ```
14 | $ yarn start
15 | ```
16 |
17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
18 |
19 | ### Build
20 |
21 | ```
22 | $ yarn build
23 | ```
24 |
25 | This command generates static content into the `build` directory and can be served using any static contents hosting service.
26 |
27 | ### Deployment
28 |
29 | Using SSH:
30 |
31 | ```
32 | $ USE_SSH=true yarn deploy
33 | ```
34 |
35 | Not using SSH:
36 |
37 | ```
38 | $ GIT_USER= yarn deploy
39 | ```
40 |
41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
42 |
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Radarr/FetcharrRadarrConfiguration.cs:
--------------------------------------------------------------------------------
1 | using YamlDotNet.Serialization;
2 |
3 | namespace Fetcharr.Models.Configuration.Radarr
4 | {
5 | ///
6 | /// Representation of a Radarr configuration.
7 | ///
8 | public sealed class FetcharrRadarrConfiguration : FetcharrServiceConfiguration
9 | {
10 | ///
11 | /// Gets or sets the minimum availability of the movie, before attempting to fetch it.
12 | ///
13 | [YamlMember(Alias = "minimum_availability")]
14 | public RadarrMovieStatus MinimumAvailability { get; set; } = RadarrMovieStatus.Released;
15 |
16 | ///
17 | /// Gets or sets which items should be monitored, when adding the movie.
18 | ///
19 | [YamlMember(Alias = "monitored_items")]
20 | public RadarrMonitoredItems MonitoredItems { get; set; } = RadarrMonitoredItems.MovieOnly;
21 | }
22 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | // Samples
8 | {
9 | "name": "Serve API",
10 | "type": "coreclr",
11 | "request": "launch",
12 | "preLaunchTask": "build",
13 | "program": "${workspaceFolder}/src/API/src/bin/Debug/net8.0/Fetcharr.API.dll",
14 | "args": [],
15 | "cwd": "${workspaceFolder}/src/API/src",
16 | "console": "internalConsole",
17 | "presentation": {
18 | "group": "General",
19 | "order": 1
20 | }
21 | },
22 | {
23 | "name": ".NET Core Attach",
24 | "type": "coreclr",
25 | "request": "attach"
26 | }
27 | ]
28 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Extensions/ArgumentInfoExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 |
3 | using static DotNet.Testcontainers.Guard;
4 |
5 | namespace Fetcharr.Testing.Containers.Extensions
6 | {
7 | public static partial class ArgumentInfoExtensions
8 | {
9 | public static ref readonly ArgumentInfo HasLength(
10 | this in ArgumentInfo argument,
11 | int length,
12 | string? exceptionMessage = null)
13 | {
14 | if(argument.Value.Length == length)
15 | {
16 | return ref argument;
17 | }
18 |
19 | throw new ArgumentException(
20 | exceptionMessage ??
21 | string.Format(
22 | CultureInfo.InvariantCulture,
23 | "'{0}' must have length of {1}.",
24 | argument.Name,
25 | length),
26 | argument.Name);
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/Queues/BaseBoundedTaskQueue.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Channels;
2 |
3 | namespace Fetcharr.API.Pipeline.Queues
4 | {
5 | ///
6 | /// Base class for a bounded task queue, i.e. a task queue with a pre-defined capacity of .
7 | ///
8 | /// Type of item to store in the queue.
9 | /// Maximum amount of items to keep in the queue.
10 | /// Behaviour for when the queue is full. See .
11 | public abstract class BaseBoundedTaskQueue(
12 | int capacity,
13 | BoundedChannelFullMode fullMode = BoundedChannelFullMode.Wait)
14 | : BaseTaskQueue
15 | {
16 | protected override Channel Queue { get; init; } =
17 | Channel.CreateBounded(new BoundedChannelOptions(capacity)
18 | {
19 | FullMode = fullMode
20 | });
21 | }
22 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Watchlist/WatchlistMetadataItem.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Plex.Models
2 | {
3 | ///
4 | /// Representation of a single watchlist item within a Plex watchlist.
5 | ///
6 | public class WatchlistMetadataItem
7 | {
8 | ///
9 | /// Gets or sets the title of the item.
10 | ///
11 | public string Title { get; set; } = string.Empty;
12 |
13 | ///
14 | /// Gets or sets the rating key of the item, which can be used to retrieve metadata.
15 | ///
16 | public string RatingKey { get; set; } = string.Empty;
17 |
18 | ///
19 | /// Gets or sets the year of the item's release.
20 | ///
21 | public int Year { get; set; }
22 |
23 | ///
24 | /// Gets or sets the type of item this is.
25 | ///
26 | public WatchlistMetadataItemType Type { get; set; } = WatchlistMetadataItemType.Show;
27 | }
28 | }
--------------------------------------------------------------------------------
/src/Fetcharr.slnf:
--------------------------------------------------------------------------------
1 | {
2 | "solution": {
3 | "path": "Fetcharr.sln",
4 | "projects": [
5 | "..\\.build\\_build.csproj",
6 | "API\\src\\Fetcharr.API.csproj",
7 | "Cache\\Core\\src\\Fetcharr.Cache.Core.csproj",
8 | "Cache\\Hybrid\\src\\Fetcharr.Cache.Hybrid.csproj",
9 | "Cache\\InMemory\\src\\Fetcharr.Cache.InMemory.csproj",
10 | "Cache\\SQLite\\src\\Fetcharr.Cache.SQLite.csproj",
11 | "Models\\src\\Fetcharr.Models.csproj",
12 | "Models\\test\\Fetcharr.Models.Tests.csproj",
13 | "Provider\\src\\Fetcharr.Provider.csproj",
14 | "Provider.Plex\\src\\Fetcharr.Provider.Plex.csproj",
15 | "Provider.Radarr\\src\\Fetcharr.Provider.Radarr.csproj",
16 | "Provider.Radarr\\test\\Fetcharr.Provider.Radarr.Tests.csproj",
17 | "Provider.Sonarr\\src\\Fetcharr.Provider.Sonarr.csproj",
18 | "Provider.Sonarr\\test\\Fetcharr.Provider.Sonarr.Tests.csproj",
19 | "Testing\\Containers\\src\\Fetcharr.Testing.Containers.csproj",
20 | "Testing\\Layers\\src\\Fetcharr.Testing.Layers.csproj"
21 | ]
22 | }
23 | }
--------------------------------------------------------------------------------
/src/Models/src/Environment.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Models
2 | {
3 | ///
4 | /// Contract for interfacing with environment environment variables.
5 | ///
6 | public interface IEnvironment
7 | {
8 | ///
9 | string? GetEnvironmentVariable(string name);
10 |
11 | ///
12 | void SetEnvironmentVariable(string name, string? value);
13 | }
14 |
15 | ///
16 | /// Default implementation for , which uses actual environment variables.
17 | ///
18 | public class DefaultEnvironment : IEnvironment
19 | {
20 | ///
21 | public string? GetEnvironmentVariable(string name)
22 | => Environment.GetEnvironmentVariable(name);
23 |
24 | ///
25 | public void SetEnvironmentVariable(string name, string? value)
26 | => Environment.SetEnvironmentVariable(name, value);
27 | }
28 | }
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Radarr/RadarrMovieStatus.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Models.Configuration.Radarr
4 | {
5 | ///
6 | /// Enumeration of all available movie statuses in Radarr.
7 | ///
8 | [JsonConverter(typeof(JsonStringEnumConverter))]
9 | public enum RadarrMovieStatus
10 | {
11 | ///
12 | /// The movie has no official trailer or release date.
13 | ///
14 | TBA,
15 |
16 | ///
17 | /// The movie has been teased via trailer or given a release date.
18 | ///
19 | Announced,
20 |
21 | ///
22 | /// The movie is currently in cinemas.
23 | ///
24 | InCinemas,
25 |
26 | ///
27 | /// The movie has been released on physical media or on digital streaming services.
28 | ///
29 | Released,
30 |
31 | ///
32 | /// This movie is no more.
33 | ///
34 | Deleted,
35 | }
36 | }
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=https://gitpod.io/schemas/gitpod-schema.json
3 |
4 | image:
5 | file: .gitpod.Dockerfile
6 |
7 | tasks:
8 | - name: Run dev (docs)
9 | before: cd docs
10 | init: npm ci
11 | command: npm run start
12 |
13 | - name: Build Release (docs)
14 | before: cd docs
15 | init: npm ci
16 | command: npm run build
17 |
18 | - name: Build (frontend)
19 | init: dotnet build src/Fetcharr.sln
20 |
21 | - name: Run (frontend)
22 | command: dotnet run --project src/Web/src/Fetcharr.Web.csproj
23 |
24 | - name: Watch (frontend)
25 | command: dotnet watch --project src/Web/src/Fetcharr.Web.csproj
26 |
27 | ports:
28 | - name: Frontend / API
29 | port: 5656
30 | protocol: http
31 | onOpen: notify
32 |
33 | - name: Documentation
34 | port: 3000
35 | protocol: http
36 | onOpen: notify
37 |
38 | vscode:
39 | extensions:
40 | - ms-dotnettools.vscode-dotnet-runtime
41 | - muhammad-sammy.csharp
42 | - editorconfig.editorconfig
43 | - bradlc.vscode-tailwindcss
44 | - redhat.vscode-yaml
45 | - esbenp.prettier-vscode
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Max T. Kristiansen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/src/Cache/Hybrid/src/HybridCachingProviderOptions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 | using Fetcharr.Cache.InMemory;
3 | using Fetcharr.Cache.SQLite;
4 |
5 | namespace Fetcharr.Cache.Hybrid
6 | {
7 | ///
8 | /// Options for the hybrid caching provider, .
9 | ///
10 | public class HybridCachingProviderOptions(string name) : BaseCachingProviderOptions(name)
11 | {
12 | ///
13 | /// Gets or sets the options of the in-memory cache.
14 | ///
15 | public InMemoryCachingProviderOptions InMemory { get; set; } = new($"{name}-mem")
16 | {
17 | DefaultExpiration = TimeSpan.FromMinutes(5),
18 | SizeLimit = 512,
19 | EnableLogging = false,
20 | };
21 |
22 | ///
23 | /// Gets or sets the options of the disk cache.
24 | ///
25 | public SQLiteCachingProviderOptions SQLite { get; set; } = new($"{name}-disk")
26 | {
27 | DefaultExpiration = TimeSpan.FromHours(1),
28 | EnableLogging = false,
29 | };
30 | }
31 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Metadata/PlexMetadataGenre.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Representation of a single genre within a Plex metadata item.
7 | ///
8 | public class PlexMetadataGenre
9 | {
10 | [JsonPropertyName("id")]
11 | public string Id { get; set; } = string.Empty;
12 |
13 | [JsonPropertyName("key")]
14 | public string Key { get; set; } = string.Empty;
15 |
16 | [JsonPropertyName("ratingKey")]
17 | public string RatingKey { get; set; } = string.Empty;
18 |
19 | [JsonPropertyName("slug")]
20 | public string Slug { get; set; } = string.Empty;
21 |
22 | [JsonPropertyName("tag")]
23 | public string Tag { get; set; } = string.Empty;
24 |
25 | [JsonPropertyName("filter")]
26 | public string Filter { get; set; } = string.Empty;
27 |
28 | [JsonPropertyName("context")]
29 | public string Context { get; set; } = string.Empty;
30 |
31 | [JsonPropertyName("directory")]
32 | public bool Directory { get; set; }
33 | }
34 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/ValidationResult.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Configuration.Validation
2 | {
3 | public class ValidationResult(string? errorMessage)
4 | {
5 | ///
6 | /// Gets a successful representation of .
7 | ///
8 | public static readonly ValidationResult Success = new(null);
9 |
10 | ///
11 | /// Gets the error message of the validation result, if any.
12 | ///
13 | public readonly string? ErrorMessage = errorMessage;
14 |
15 | ///
16 | /// Gets the names of the members which have validation errors, if any.
17 | ///
18 | public readonly IEnumerable InvalidMemberNames = [];
19 |
20 | ///
21 | /// Gets whether the result is sucessful.
22 | ///
23 | public bool IsSuccess => this.ErrorMessage is null;
24 |
25 | public ValidationResult(string errorMessage, IEnumerable invalidMembers)
26 | : this(errorMessage)
27 | => this.InvalidMemberNames = invalidMembers;
28 | }
29 | }
--------------------------------------------------------------------------------
/src/API/src/Services/StartupInformationService.cs.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | namespace Fetcharr.API.Services
4 | {
5 | ///
6 | /// Hosted service for logging information about the environment on startup.
7 | ///
8 | public class StartupInformationService(
9 | IAppDataSetup appDataSetup,
10 | ILogger logger)
11 | : BackgroundService
12 | {
13 | protected override async Task ExecuteAsync(CancellationToken cancellationToken)
14 | {
15 | logger.LogInformation(
16 | "Fetcharr v{Version} ({BranchName}-{ShortSha}):",
17 | GitVersionInformation.SemVer,
18 | GitVersionInformation.BranchName,
19 | GitVersionInformation.ShortSha);
20 |
21 | logger.LogInformation(" Base directory: {Path}", appDataSetup.BaseDirectory);
22 | logger.LogInformation(" Cache directory: {Path}", appDataSetup.CacheDirectory);
23 | logger.LogInformation(" Config directory: {Path}", appDataSetup.ConfigDirectory);
24 |
25 | await Task.CompletedTask;
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Migrations/20240724145919_InitialMigration.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | using Microsoft.EntityFrameworkCore.Migrations;
4 |
5 | #nullable disable
6 |
7 | namespace Fetcharr.Cache.SQLite.Migrations
8 | {
9 | ///
10 | public partial class InitialMigration : Migration
11 | {
12 | ///
13 | protected override void Up(MigrationBuilder migrationBuilder)
14 | {
15 | migrationBuilder.CreateTable(
16 | name: "Items",
17 | columns: table => new
18 | {
19 | Key = table.Column(type: "TEXT", nullable: false),
20 | Value = table.Column(type: "TEXT", nullable: true),
21 | ExpiresAt = table.Column(type: "TEXT", nullable: false)
22 | },
23 | constraints: table => table.PrimaryKey("PK_Items", x => x.Key));
24 | }
25 |
26 | ///
27 | protected override void Down(MigrationBuilder migrationBuilder)
28 | {
29 | migrationBuilder.DropTable(
30 | name: "Items");
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/.github/workflows/stale.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2 |
3 | name: Close stale issues and PRs
4 |
5 | on:
6 | schedule:
7 | - cron: '0 0 * * *'
8 |
9 | jobs:
10 | stale:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/stale@v9
14 | with:
15 | days-before-stale: 30
16 | days-before-issue-close: 7
17 | days-before-pr-close: -1
18 |
19 | stale-issue-label: stale
20 | stale-pr-label: stale
21 |
22 | stale-issue-message: >
23 | This issue has been automatically marked as stale because it has not had
24 | recent activity. It will be closed if no further activity occurs. Thank you
25 | for your contributions.
26 |
27 | stale-pr-message: >
28 | This pull request has been automatically marked as stale because it has not had
29 | recent activity. It will be closed if no further activity occurs. Thank you
30 | for your contributions.
31 |
32 | close-issue-message: >
33 | This issue was closed because it has been stalled for 7 days with no activity.
34 |
--------------------------------------------------------------------------------
/src/Cache/Core/src/Services/CacheInitializationService.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 | using Microsoft.Extensions.Hosting;
3 | using Microsoft.Extensions.Logging;
4 |
5 | namespace Fetcharr.Cache.Core.Services
6 | {
7 | public class CacheInitializationService(
8 | ILogger logger,
9 | IServiceProvider services)
10 | : IHostedService
11 | {
12 | public async Task StartAsync(CancellationToken cancellationToken)
13 | {
14 | logger.LogInformation("Initializing caches.");
15 |
16 | using IServiceScope scope = services.CreateScope();
17 |
18 | foreach(ICachingProvider provider in scope.ServiceProvider.GetServices())
19 | {
20 | await provider.InitializeAsync(cancellationToken);
21 | }
22 | }
23 |
24 | public async Task StopAsync(CancellationToken cancellationToken)
25 | {
26 | foreach(ICachingProvider provider in services.GetServices())
27 | {
28 | await provider.FlushAsync(cancellationToken);
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Fetcharr.Testing.Containers.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Testing.Containers
5 | Fetcharr.Testing.Containers
6 | Fetcharr.Testing.Containers
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/Testing/Layers/src/BaseServiceTestingLayer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.API.Extensions;
2 | using Fetcharr.Provider;
3 |
4 | using Microsoft.Extensions.DependencyInjection;
5 |
6 | namespace Fetcharr.Testing.Layers
7 | {
8 | ///
9 | /// Base layer for testing Fetcharr services.
10 | ///
11 | /// Type of service to test.
12 | public abstract class BaseServiceTestingLayer
13 | where TService : ExternalProvider
14 | {
15 | ///
16 | /// Gets a -instance with all Fetcharr services registered.
17 | ///
18 | private readonly IServiceProvider _provider = new ServiceCollection()
19 | .AddFetcharr()
20 | .BuildServiceProvider();
21 |
22 | ///
23 | /// Creates a service of type ,
24 | /// with optional constructor arguments from .
25 | ///
26 | protected T CreateService(params object[] parameters)
27 | => ActivatorUtilities.CreateInstance(this._provider, parameters);
28 | }
29 | }
--------------------------------------------------------------------------------
/src/Testing/Layers/src/RadarrIntegrationLayer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Containers.Radarr;
2 |
3 | using Flurl.Http;
4 |
5 | using Xunit;
6 |
7 | namespace Fetcharr.Testing.Layers
8 | {
9 | ///
10 | /// Base layer for testing Radarr instances, using TestContainers.
11 | ///
12 | public class RadarrIntegrationLayer
13 | : RadarrTestingLayer
14 | , IAsyncLifetime
15 | {
16 | ///
17 | /// Gets the Radarr container instance.
18 | ///
19 | private readonly RadarrContainer _container = new RadarrBuilder()
20 | .Build();
21 |
22 | ///
23 | /// Gets an HTTP client for interacting with the Radarr instance.
24 | ///
25 | public FlurlClient RadarrApiClient =>
26 | new FlurlClient(this._container.EndpointBase)
27 | .WithHeader("X-Api-Key", this._container.Configuration.ApiKey);
28 |
29 | public async Task InitializeAsync()
30 | => await this._container.StartAsync();
31 |
32 | public async Task DisposeAsync()
33 | => await this._container.StopAsync();
34 | }
35 | }
--------------------------------------------------------------------------------
/src/Testing/Layers/src/SonarrIntegrationLayer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Testing.Containers.Sonarr;
2 |
3 | using Flurl.Http;
4 |
5 | using Xunit;
6 |
7 | namespace Fetcharr.Testing.Layers
8 | {
9 | ///
10 | /// Base layer for testing Sonarr instances, using TestContainers.
11 | ///
12 | public class SonarrIntegrationLayer
13 | : SonarrTestingLayer
14 | , IAsyncLifetime
15 | {
16 | ///
17 | /// Gets the Sonarr container instance.
18 | ///
19 | private readonly SonarrContainer _container = new SonarrBuilder()
20 | .Build();
21 |
22 | ///
23 | /// Gets an HTTP client for interacting with the Sonarr instance.
24 | ///
25 | public FlurlClient SonarrApiClient =>
26 | new FlurlClient(this._container.EndpointBase)
27 | .WithHeader("X-Api-Key", this._container.Configuration.ApiKey);
28 |
29 | public async Task InitializeAsync()
30 | => await this._container.StartAsync();
31 |
32 | public async Task DisposeAsync()
33 | => await this._container.StopAsync();
34 | }
35 | }
--------------------------------------------------------------------------------
/.build/Build.Publish.cs:
--------------------------------------------------------------------------------
1 | using System.IO.Compression;
2 |
3 | using Nuke.Common;
4 | using Nuke.Common.IO;
5 | using Nuke.Common.Tools.DotNet;
6 |
7 | partial class Build : NukeBuild
8 | {
9 | Target Publish => _ => _
10 | .Description("Publishes the .NET projects and builds them as single-file applications\n")
11 | .DependsOn(Compile)
12 | .Executes(() =>
13 | {
14 | string runtimeIdentifier = "win-x64";
15 |
16 | if(OperatingSystem.IsLinux())
17 | {
18 | runtimeIdentifier = "linux-x64";
19 | }
20 |
21 | DotNetTasks.DotNetPublish(c => c
22 | .SetConfiguration(Configuration)
23 | .SetProject(ApiProjectDirectory)
24 | .SetRuntime(runtimeIdentifier)
25 | .SetOutput(PublishOutputDirectory)
26 | .SetPublishSingleFile(true));
27 |
28 | PublishOutputDirectory.ZipTo(
29 | AssetsDirectory / $"fetcharr-{VersionTag}-{runtimeIdentifier}.zip",
30 | filter: x => !x.HasExtension(".pdb"),
31 | compressionLevel: CompressionLevel.SmallestSize,
32 | fileMode: FileMode.Create
33 | );
34 | });
35 | }
--------------------------------------------------------------------------------
/src/Testing/Layers/src/Fetcharr.Testing.Layers.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.Testing.Layers
5 | Fetcharr.Testing.Layers
6 | Fetcharr.Testing.Layers
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "build",
6 | "command": "dotnet",
7 | "type": "process",
8 | "args": [
9 | "build",
10 | "${workspaceFolder}/src/Beam.sln",
11 | "/property:GenerateFullPaths=true",
12 | "/consoleloggerparameters:NoSummary"
13 | ],
14 | "problemMatcher": "$msCompile"
15 | },
16 | {
17 | "label": "publish",
18 | "command": "dotnet",
19 | "type": "process",
20 | "args": [
21 | "publish",
22 | "${workspaceFolder}/src/Beam.sln",
23 | "/property:GenerateFullPaths=true",
24 | "/consoleloggerparameters:NoSummary"
25 | ],
26 | "problemMatcher": "$msCompile"
27 | },
28 | {
29 | "label": "watch",
30 | "command": "dotnet",
31 | "type": "process",
32 | "args": [
33 | "watch",
34 | "run",
35 | "--project",
36 | "${workspaceFolder}/src/Beam.sln"
37 | ],
38 | "problemMatcher": "$msCompile"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Secrets/SecretsDeserializer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Configuration.EnvironmentVariables.Exceptions;
2 |
3 | using YamlDotNet.Core;
4 | using YamlDotNet.Core.Events;
5 | using YamlDotNet.Serialization;
6 |
7 | namespace Fetcharr.Configuration.Secrets
8 | {
9 | public record SecretsValue;
10 |
11 | public class SecretsDeserializer(
12 | ISecretsProvider secretsProvider)
13 | : INodeDeserializer
14 | {
15 | public bool Deserialize(
16 | IParser reader,
17 | Type expectedType,
18 | Func nestedObjectDeserializer,
19 | out object? value,
20 | ObjectDeserializer rootDeserializer)
21 | {
22 | if(expectedType != typeof(SecretsValue))
23 | {
24 | value = null;
25 | return false;
26 | }
27 |
28 | Scalar scalar = reader.Consume();
29 | string secretName = scalar.Value;
30 |
31 | if(secretsProvider.Secrets.TryGetValue(secretName, out string? secretValue))
32 | {
33 | value = secretValue;
34 | return true;
35 | }
36 |
37 | throw new SecretValueNotFoundException(secretName);
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/docs/docs/configuration/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: configuration
3 | sidebar_position: 1
4 | ---
5 |
6 | # Configuration
7 |
8 | Fetcharr takes use of YAML for configuration. It allows for easy and flexible configuration, without having to configure everything in a UI. Fetcharr has some extra capabilities[^attribution], which allow for more flexability:
9 | - You can split up your configuration file using [YAML includes](include), allowing you to segment some configuration properties into their own files. That way, you can have a configuration file for each type of instance.
10 | - You can specify configuration properties outside of your configurtion file. Fetcharr has support for reading [environment variables](environment-variables) and [secrets](secrets), to prevent leaking API keys and other sensitive information.
11 |
12 | To get started, head over to [Basic Setup](./basic-setup).
13 |
14 | ## Schema Validation
15 |
16 | Go to [Schema Validation](./schema-validation) for detailed instructions.
17 |
18 | [^attribution]: Some of these features - notibly secrets, environment variables and includes - were heavily inspired by
19 | the [Recyclarr](https://github.com/recyclarr/recyclarr) project, as listed on [Introduction](/index.mdx#contributing).
20 | Their implementation is nicely implemented, easy to use and genuinely useful.
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Migrations/CacheContextModelSnapshot.cs:
--------------------------------------------------------------------------------
1 | //
2 | using System;
3 | using Fetcharr.Cache.SQLite.Contexts;
4 | using Microsoft.EntityFrameworkCore;
5 | using Microsoft.EntityFrameworkCore.Infrastructure;
6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 |
8 | #nullable disable
9 |
10 | namespace Fetcharr.Cache.SQLite.Migrations
11 | {
12 | [DbContext(typeof(CacheContext))]
13 | partial class CacheContextModelSnapshot : ModelSnapshot
14 | {
15 | protected override void BuildModel(ModelBuilder modelBuilder)
16 | {
17 | #pragma warning disable 612, 618
18 | modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
19 |
20 | modelBuilder.Entity("Fetcharr.Cache.SQLite.Models.CacheItem", b =>
21 | {
22 | b.Property("Key")
23 | .HasColumnType("TEXT");
24 |
25 | b.Property("ExpiresAt")
26 | .HasColumnType("TEXT");
27 |
28 | b.Property("Value")
29 | .HasColumnType("TEXT");
30 |
31 | b.HasKey("Key");
32 |
33 | b.ToTable("Items");
34 | });
35 | #pragma warning restore 612, 618
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Provider.Plex/src/PlexFriendsWatchlistClient.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Provider.Plex.Clients;
2 | using Fetcharr.Provider.Plex.Models;
3 |
4 | namespace Fetcharr.Provider.Plex
5 | {
6 | ///
7 | /// Client for fetching friends' watchlists from Plex.
8 | ///
9 | public class PlexFriendsWatchlistClient(
10 | PlexGraphQLClient plexGraphQLClient)
11 | {
12 | ///
13 | /// Fetch the watchlists for all the friends the current Plex account and return them.
14 | ///
15 | /// Maximum amount of items to fetch per watchlist.
16 | public async Task> FetchAllWatchlistsAsync(int count = 10)
17 | {
18 | List joinedWatchlist = [];
19 | IEnumerable friends = await plexGraphQLClient.GetAllFriendsAsync();
20 |
21 | foreach(PlexFriendUser friend in friends)
22 | {
23 | IEnumerable friendWatchlist = await plexGraphQLClient
24 | .GetFriendWatchlistAsync(friend.Id, count);
25 |
26 | joinedWatchlist.AddRange(friendWatchlist);
27 | }
28 |
29 | return joinedWatchlist;
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Configuration.Parsing;
2 | using Fetcharr.Configuration.Secrets;
3 | using Fetcharr.Models.Configuration;
4 |
5 | using Microsoft.Extensions.DependencyInjection;
6 | using Microsoft.Extensions.Options;
7 |
8 | namespace Fetcharr.Configuration.Extensions
9 | {
10 | public static partial class IServiceCollectionExtensions
11 | {
12 | ///
13 | /// Registers configuration services onto the given .
14 | ///
15 | public static IServiceCollection AddConfiguration(this IServiceCollection services)
16 | {
17 | services.AddScoped();
18 | services.AddScoped();
19 | services.AddScoped();
20 |
21 | services.AddTransient>(provider =>
22 | {
23 | IConfigurationParser parser = provider.GetRequiredService();
24 | FetcharrConfiguration configuration = parser.ReadConfig();
25 |
26 | return Options.Create(configuration);
27 | });
28 |
29 | return services;
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/src/Cache/Core/src/CacheValue.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Cache.Core
2 | {
3 | public class CacheValue(T? value, bool hasValue)
4 | {
5 | public static CacheValue Null { get; } = new CacheValue(default, true);
6 |
7 | public static CacheValue NoValue { get; } = new CacheValue(default, false);
8 |
9 | private readonly T? _value = value;
10 |
11 | ///
12 | /// Gets the underlying value of the .
13 | ///
14 | /// Thrown if the has no value.
15 | public T Value
16 | {
17 | get
18 | {
19 | if(!this.HasValue)
20 | {
21 | throw new InvalidDataException($"CacheValue<{nameof(T)}>.Value is null.");
22 | }
23 |
24 | return this._value ?? default!;
25 | }
26 | }
27 |
28 | ///
29 | /// Gets whether has any value.
30 | ///
31 | public bool HasValue { get; } = hasValue;
32 |
33 | ///
34 | /// Gets whether is .
35 | ///
36 | public bool IsNull => this._value is null;
37 | }
38 | }
--------------------------------------------------------------------------------
/.build/Build.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.Execution;
3 |
4 | [UnsetVisualStudioEnvironmentVariables]
5 | partial class Build : NukeBuild
6 | {
7 | public static int Main() => Execute(x => x.Compile);
8 |
9 | [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")]
10 | readonly Configuration Configuration = IsLocalBuild
11 | ? Configuration.Debug
12 | : Configuration.Release;
13 |
14 | [Secret]
15 | [Parameter("GitHub Token for pushing Docker images to GHCR")]
16 | readonly string GithubToken;
17 |
18 | protected override void OnBuildInitialized()
19 | {
20 | Serilog.Log.Information("🔥 Build process started");
21 | Serilog.Log.Information(" Repository: {Repository}", this.Repository.HttpsUrl);
22 | Serilog.Log.Information(" Version: {Version}", this.VersionTag);
23 | Serilog.Log.Information(" Tags: {VersionTags}", this.VersionTags);
24 | Serilog.Log.Information(" IsRelease: {IsReleaseBuild}", this.IsReleaseBuild);
25 |
26 | if(this.GitHubActions is not null)
27 | {
28 | Serilog.Log.Information(" Branch: {BranchName}", this.GitHubActions.Ref);
29 | Serilog.Log.Information(" Commit: {CommitSha}", this.GitHubActions.Sha);
30 | }
31 |
32 | base.OnBuildInitialized();
33 | }
34 | }
--------------------------------------------------------------------------------
/src/API/src/Fetcharr.API.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Fetcharr.API
5 | Fetcharr.API
6 | Fetcharr.API
7 | Source
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | All
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fetcharr-docs",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "docusaurus": "docusaurus",
7 | "start": "docusaurus start --host 0.0.0.0",
8 | "build": "docusaurus build",
9 | "swizzle": "docusaurus swizzle",
10 | "deploy": "docusaurus deploy",
11 | "clear": "docusaurus clear",
12 | "serve": "docusaurus serve --host 0.0.0.0",
13 | "write-translations": "docusaurus write-translations",
14 | "write-heading-ids": "docusaurus write-heading-ids",
15 | "typecheck": "tsc"
16 | },
17 | "dependencies": {
18 | "@docusaurus/core": "3.8.1",
19 | "@docusaurus/preset-classic": "3.8.1",
20 | "@iconify/react": "^6.0.2",
21 | "@mdx-js/react": "^3.1.0",
22 | "clsx": "^2.1.0",
23 | "prism-react-renderer": "^2.4.0",
24 | "react": "^19.1.0",
25 | "react-dom": "^19.1.0"
26 | },
27 | "devDependencies": {
28 | "@docusaurus/module-type-aliases": "3.8.1",
29 | "@docusaurus/tsconfig": "3.8.1",
30 | "@docusaurus/types": "3.8.1",
31 | "typescript": "~5.9.2"
32 | },
33 | "browserslist": {
34 | "production": [
35 | ">0.5%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 3 chrome version",
41 | "last 3 firefox version",
42 | "last 5 safari version"
43 | ]
44 | },
45 | "engines": {
46 | "node": ">=18.0"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Migrations/20240724145919_InitialMigration.Designer.cs:
--------------------------------------------------------------------------------
1 | //
2 | using System;
3 | using Fetcharr.Cache.SQLite.Contexts;
4 | using Microsoft.EntityFrameworkCore;
5 | using Microsoft.EntityFrameworkCore.Infrastructure;
6 | using Microsoft.EntityFrameworkCore.Migrations;
7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
8 |
9 | #nullable disable
10 |
11 | namespace Fetcharr.Cache.SQLite.Migrations
12 | {
13 | [DbContext(typeof(CacheContext))]
14 | [Migration("20240724145919_InitialMigration")]
15 | partial class InitialMigration
16 | {
17 | ///
18 | protected override void BuildTargetModel(ModelBuilder modelBuilder)
19 | {
20 | #pragma warning disable 612, 618
21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
22 |
23 | modelBuilder.Entity("Fetcharr.Cache.SQLite.Models.CacheItem", b =>
24 | {
25 | b.Property("Key")
26 | .HasColumnType("TEXT");
27 |
28 | b.Property("ExpiresAt")
29 | .HasColumnType("TEXT");
30 |
31 | b.Property("Value")
32 | .HasColumnType("TEXT");
33 |
34 | b.HasKey("Key");
35 |
36 | b.ToTable("Items");
37 | });
38 | #pragma warning restore 612, 618
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2 |
3 | name: Deploy docs to Github Pages
4 |
5 | on:
6 | push:
7 | branches:
8 | - main
9 | paths:
10 | - 'docs/**'
11 | workflow_dispatch:
12 |
13 | defaults:
14 | run:
15 | working-directory: docs/
16 |
17 | jobs:
18 | build:
19 | name: Build Docusaurus
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Checkout
23 | uses: actions/checkout@v4
24 | with:
25 | fetch-depth: 0
26 |
27 | - uses: actions/setup-node@v4
28 | with:
29 | node-version: lts/*
30 | cache: npm
31 | cache-dependency-path: docs/package-lock.json
32 |
33 | - name: Install dependencies
34 | run: npm ci
35 |
36 | - name: Build website
37 | run: npm run build
38 |
39 | - name: Upload Build Artifact
40 | uses: actions/upload-pages-artifact@v3
41 | with:
42 | path: docs/build
43 |
44 | deploy:
45 | name: Deploy to GitHub Pages
46 | needs: build
47 |
48 | permissions:
49 | pages: write
50 | id-token: write
51 |
52 | environment:
53 | name: github-pages
54 | url: ${{ steps.deployment.outputs.page_url }}
55 |
56 | runs-on: ubuntu-latest
57 | steps:
58 | - name: Deploy to GitHub Pages
59 | id: deployment
60 | uses: actions/deploy-pages@v4
--------------------------------------------------------------------------------
/src/Cache/Core/src/BaseCachingProviderOptions.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Cache.Core
2 | {
3 | ///
4 | /// Base class for all caching provider options.
5 | ///
6 | public abstract class BaseCachingProviderOptions(string name)
7 | {
8 | ///
9 | /// Gets or sets the name of the cache.
10 | ///
11 | ///
12 | /// Used for identification - does not have to be unique.
13 | ///
14 | public string Name { get; init; } = name;
15 |
16 | ///
17 | /// Gets or sets whether to log cache-hits and -misses to console output.
18 | ///
19 | public bool EnableLogging { get; set; } = false;
20 |
21 | ///
22 | /// Gets or sets the random value added onto expiration times, in seconds.
23 | ///
24 | ///
25 | /// Used to prevent Cache Crash.
26 | ///
27 | ///
28 | public int MaxExpirationDilation { get; set; } = 120;
29 |
30 | ///
31 | /// Gets or sets the default expiration, when none is explicitly set.
32 | ///
33 | public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromHours(4);
34 | }
35 | }
--------------------------------------------------------------------------------
/docs/docs/configuration/secrets.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: secrets
3 | sidebar_position: 7
4 | ---
5 |
6 | # Secrets
7 |
8 | Secrets can be used to hide sensitive information, when sharing configuration files. Much like [environment variables](environment-variables), they are used to replace configuration properties in-place.
9 |
10 | Secrets are defined in `secrets.yaml`, located in the same directory as `fetcharr.yaml`. If you don't use secrets, this file is completely optional.
11 |
12 | ## Defining secrets
13 |
14 | You define secrets in `secrets.yaml` as key-value pairs.
15 |
16 | ```yaml
17 | secret_key: Secret Value
18 | ```
19 |
20 | where `secret_key` is the name of the secret. The key can be anything, as long as it's unique within the file and complies with the YAML spec. Likewise, the value can be any type that YAML allows for.
21 |
22 | ## Using secrets
23 |
24 | To use a secret in a configuration file, you use the `!secret` YAML tag:
25 |
26 | ```yaml
27 | !secret secret_key
28 | ```
29 |
30 | ## Example
31 |
32 | Given this sample `secrets.yaml` file:
33 | ```yaml title="secrets.yaml"
34 | sonarr_api_key: 5aec487a70b5417e880d3923e4786d18
35 | ```
36 |
37 | A configuration file like this:
38 | ```yaml
39 | sonarr:
40 | default:
41 | base_url: http://localhost:7878
42 | api_key: !secret sonarr_api_key
43 | ```
44 |
45 | would effectively be replaced with:
46 | ```yaml
47 | sonarr:
48 | default:
49 | base_url: http://localhost:7878
50 | api_key: 5aec487a70b5417e880d3923e4786d18
51 | ```
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "search.exclude": {
3 | "**/node_modules": true,
4 | "**/bower_components": true,
5 | "**/*.code-search": true,
6 | "**/*.AssemblyInfo.cs": true,
7 | "**/bin/*": true,
8 | "**/obj/*": true,
9 | },
10 | "editor.rulers": [0, 120],
11 | "files.trimTrailingWhitespace": true,
12 | "files.associations": {
13 | "*.Build.props": "xml",
14 | "*.Build.targets": "xml",
15 | "*.slnf": "json",
16 | "*.json": "jsonc",
17 | "*.mdx": "markdown",
18 | },
19 | "omnisharp.autoStart": true,
20 | "dotnet.defaultSolution": "src/Fetcharr.sln",
21 | "markdown.styles": [
22 | "https://use.fontawesome.com/releases/v5.7.1/css/all.css"
23 | ],
24 | "[yaml]": {
25 | "editor.defaultFormatter": "redhat.vscode-yaml",
26 | "editor.insertSpaces": true,
27 | "editor.tabSize": 2,
28 | "editor.autoIndent": "keep",
29 | "diffEditor.ignoreTrimWhitespace": false,
30 | "editor.quickSuggestions": {
31 | "other": true,
32 | "comments": false,
33 | "strings": true
34 | }
35 | },
36 | "[markdown]": {
37 | "editor.wordWrap": "wordWrapColumn",
38 | "editor.wordWrapColumn": 120
39 | },
40 | "[jsonc]": {
41 | "editor.insertSpaces": true,
42 | "editor.tabSize": 2,
43 | "editor.autoIndent": "full",
44 | "diffEditor.ignoreTrimWhitespace": false,
45 | "editor.formatOnSave": true,
46 | "editor.formatOnPaste": true
47 | },
48 | "yaml.customTags": [
49 | "env_var",
50 | "secret",
51 | ]
52 | }
--------------------------------------------------------------------------------
/src/Shared/src/GraphQL/GraphQLHttpClientExtensions.cs:
--------------------------------------------------------------------------------
1 | using GraphQL;
2 | using GraphQL.Client.Http;
3 |
4 | namespace Fetcharr.Shared.GraphQL
5 | {
6 | public static class GraphQLHttpClientExtensions
7 | {
8 | ///
9 | /// Appends a default HTTP header to send along with all GraphQL operations.
10 | ///
11 | /// -instance to add the header onto.
12 | /// to allow for chaining calls.
13 | public static GraphQLHttpClient WithHeader(this GraphQLHttpClient client, string name, string value)
14 | {
15 | client.HttpClient.DefaultRequestHeaders.Add(name, value);
16 | return client;
17 | }
18 |
19 | ///
20 | /// Enables Automatic Persisted Queries, when resolve to .
21 | ///
22 | /// -instance to enable APQ for.
23 | /// to allow for chaining calls.
24 | public static GraphQLHttpClient WithAutomaticPersistedQueries(
25 | this GraphQLHttpClient client,
26 | Func? when = null)
27 | {
28 | when ??= _ => true;
29 |
30 | client.Options.EnableAutomaticPersistedQueries = when;
31 | return client;
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Configuration.Validation;
2 | using Fetcharr.Configuration.Validation.Rules;
3 |
4 | using Microsoft.Extensions.DependencyInjection;
5 |
6 | namespace Fetcharr.Configuration.Extensions
7 | {
8 | public static partial class IServiceCollectionExtensions
9 | {
10 | ///
11 | /// Registers validation onto the given .
12 | ///
13 | public static IServiceCollection AddValidation(this IServiceCollection services) =>
14 | services
15 | .AddScoped()
16 | .AddDefaultValidationRules();
17 |
18 | ///
19 | /// Registers default validation rules onto the given .
20 | ///
21 | public static IServiceCollection AddDefaultValidationRules(this IServiceCollection services) =>
22 | services
23 | .AddValidationRule()
24 | .AddValidationRule();
25 |
26 | ///
27 | /// Registers a validation rule onto the given .
28 | ///
29 | public static IServiceCollection AddValidationRule(this IServiceCollection services)
30 | where TRule : class, IValidationRule
31 | => services.AddScoped();
32 | }
33 | }
--------------------------------------------------------------------------------
/src/API/src/Services/ProviderPingService.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.API.Pipeline;
2 | using Fetcharr.Provider;
3 | using Fetcharr.Provider.Exceptions;
4 |
5 | namespace Fetcharr.API.Services
6 | {
7 | ///
8 | /// Hosted service for pinging external services and verifying the connection to them.
9 | ///
10 | public class ProviderPingService(
11 | IEnumerable providers,
12 | ILogger logger)
13 | : BasePeriodicService(TimeSpan.FromSeconds(15), logger)
14 | {
15 | public override async Task InvokeAsync(CancellationToken cancellationToken)
16 | {
17 | foreach(ExternalProvider provider in providers)
18 | {
19 | try
20 | {
21 | await provider.PingAsync(cancellationToken);
22 | }
23 | catch(ExternalProviderUnreachableException ex)
24 | {
25 | logger.LogError(
26 | "Provider '{ProviderName}' is unreachable: {ExceptionMessage}",
27 | ex.ProviderName,
28 | ex.InnerException?.Message ?? ex.Message);
29 | }
30 | catch(Exception ex)
31 | {
32 | logger.LogError(
33 | ex, "Provider '{ProviderName}' is unreachable: {ExceptionMessage}",
34 | provider.ProviderName,
35 | ex.Message);
36 | }
37 | }
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/docs/docs/configuration/env_vars.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: environment-variables
3 | sidebar_position: 6
4 | ---
5 |
6 | # Environment Variables
7 |
8 | Environment variables can be used in place of defining values in your configuration file directly. This may be useful to you, if you:
9 | - want to make your configuration more dynamic,
10 | - want to define some variables in your `compose.yaml` file,
11 | - want to hide sensitive information (although [secrets](secrets) might be a better fit).
12 |
13 | The main downside of environment variables is that you can only define scalar values in YAML. Lists and maps are not supported[^1].
14 |
15 | ## Syntax
16 |
17 | ```yaml
18 | !env_var ENV_VAR_NAME default_value
19 | ```
20 |
21 | - `ENV_VAR_NAME` is the name of the environment variable to substitute in, on configuration parse. It is case-sensitive.
22 | - `default_value` is optional, allowing you define a fallback value, in case `ENV_VAR_NAME` is undefined or empty.
23 |
24 | ## Example
25 |
26 | Given this sample environment:
27 | ```bash
28 | RADARR_BASE_URL="http://localhost:7878"
29 | RADARR_API_KEY="5aec487a70b5417e880d3923e4786d18"
30 | ```
31 |
32 | A configuration file like this:
33 | ```yaml
34 | radarr:
35 | default:
36 | base_url: !env_var RADARR_BASE_URL
37 | api_key: !env_var RADARR_API_KEY
38 | ```
39 |
40 | would effectively be replaced with:
41 | ```yaml
42 | radarr:
43 | default:
44 | base_url: http://localhost:7878
45 | api_key: 5aec487a70b5417e880d3923e4786d18
46 | ```
47 |
48 | [^1]: You can still use environment variables within lists, but it cannot stand in place of the entire list.
--------------------------------------------------------------------------------
/src/Models/src/Configuration/FetcharrConfiguration.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Plex;
2 | using Fetcharr.Models.Configuration.Radarr;
3 | using Fetcharr.Models.Configuration.Sonarr;
4 |
5 | using YamlDotNet.Serialization;
6 |
7 | using static System.StringComparer;
8 |
9 | namespace Fetcharr.Models.Configuration
10 | {
11 | ///
12 | /// Represents the configuration of Fetcharr.
13 | ///
14 | public sealed class FetcharrConfiguration
15 | {
16 | ///
17 | /// Gets or sets the configuration for Plex within Fetcharr.
18 | ///
19 | [YamlMember(Alias = "plex")]
20 | public FetcharrPlexConfiguration Plex { get; set; } = new();
21 |
22 | ///
23 | /// Gets or sets the configuration for Radarr instances within Fetcharr.
24 | ///
25 | [YamlMember(Alias = "radarr")]
26 | public Dictionary Radarr { get; set; } = new(InvariantCultureIgnoreCase);
27 |
28 | ///
29 | /// Gets or sets the configuration for Sonarr instances within Fetcharr.
30 | ///
31 | [YamlMember(Alias = "sonarr")]
32 | public Dictionary Sonarr { get; set; } = new(InvariantCultureIgnoreCase);
33 |
34 | ///
35 | /// Gets or sets a list of inclusions for the configuration.
36 | ///
37 | [YamlMember(Alias = "include")]
38 | public List Includes { get; set; } = [];
39 | }
40 | }
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Models/CacheItem.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel.DataAnnotations;
2 | using System.ComponentModel.DataAnnotations.Schema;
3 | using System.Text.Json;
4 |
5 | namespace Fetcharr.Cache.SQLite.Models
6 | {
7 | public class CacheItem
8 | {
9 | [Key]
10 | [DatabaseGenerated(DatabaseGeneratedOption.None)]
11 | public string Key { get; set; }
12 |
13 | public string? Value { get; set; }
14 |
15 | [Required]
16 | public DateTime ExpiresAt { get; set; }
17 |
18 | public CacheItem(string key)
19 | {
20 | this.Key = key;
21 | }
22 |
23 | public CacheItem(string key, object? value, TimeSpan expiration)
24 | {
25 | this.Key = key;
26 | this.Value = value is null ? null : JsonSerializer.Serialize(value);
27 | this.ExpiresAt = DateTime.Now + expiration;
28 | }
29 |
30 | public async Task SetValueAsync(T? value, CancellationToken cancellationToken = default)
31 | {
32 | _ = cancellationToken;
33 |
34 | this.Value = value is not null ? JsonSerializer.Serialize(value) : null;
35 |
36 | await Task.CompletedTask;
37 | }
38 |
39 | public async Task GetValueAsync(CancellationToken cancellationToken = default)
40 | {
41 | _ = cancellationToken;
42 |
43 | if(this.Value is null)
44 | {
45 | return await Task.FromResult(default);
46 | }
47 |
48 | return JsonSerializer.Deserialize(this.Value);
49 | }
50 | }
51 | }
--------------------------------------------------------------------------------
/src/Models/src/Configuration/Sonarr/FetcharrSonarrConfiguration.cs:
--------------------------------------------------------------------------------
1 | using YamlDotNet.Serialization;
2 |
3 | namespace Fetcharr.Models.Configuration.Sonarr
4 | {
5 | ///
6 | /// Representation of a Sonarr configuration.
7 | ///
8 | public sealed class FetcharrSonarrConfiguration : FetcharrServiceConfiguration
9 | {
10 | ///
11 | /// Gets or sets the type of series.
12 | ///
13 | [YamlMember(Alias = "series_type")]
14 | public SonarrSeriesType SeriesType { get; set; } = SonarrSeriesType.Standard;
15 |
16 | ///
17 | /// Gets or sets whether to create a folder for each season.
18 | ///
19 | [YamlMember(Alias = "season_folder")]
20 | public bool SeasonFolder { get; set; } = true;
21 |
22 | ///
23 | /// Gets or sets whether to monitor new items in the series.
24 | ///
25 | [YamlMember(Alias = "monitor_new_items")]
26 | public bool MonitorNewItems { get; set; } = false;
27 |
28 | ///
29 | /// Gets or sets which items should be monitored.
30 | ///
31 | [YamlMember(Alias = "monitored_items")]
32 | public SonarrMonitoredItems MonitoredItems { get; set; } = SonarrMonitoredItems.FirstSeason;
33 |
34 | ///
35 | /// Gets or sets the threshold for what counts as a short series, in seasons.
36 | ///
37 | [YamlMember(Alias = "short_series_threshold")]
38 | public int ShortSeriesThreshold { get; set; } = 3;
39 | }
40 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/ValidationPipeline.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | using Microsoft.Extensions.Logging;
4 |
5 | namespace Fetcharr.Configuration.Validation
6 | {
7 | ///
8 | /// Representation of a validation pipeline for configuration files.
9 | ///
10 | public interface IValidationPipeline
11 | {
12 | ///
13 | /// Validates the given configuration file.
14 | /// If any validation errors occur, they are logged and method returns .
15 | /// Otherwise, returns .
16 | ///
17 | bool Validate(FetcharrConfiguration configuration);
18 | }
19 |
20 | ///
21 | /// Default implementation of .
22 | ///
23 | public class ValidationPipeline(
24 | IEnumerable rules,
25 | ILogger logger)
26 | : IValidationPipeline
27 | {
28 | public bool Validate(FetcharrConfiguration configuration)
29 | {
30 | List failures = [];
31 |
32 | foreach(IValidationRule rule in rules)
33 | {
34 | ValidationResult result = rule.Validate(configuration);
35 | if(!result.IsSuccess)
36 | {
37 | failures.Add(result);
38 | logger.LogCritical("Config validation error: {Error}", result.ErrorMessage);
39 | }
40 | }
41 |
42 | return failures.Count == 0;
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Validation/Rules/ServiceValidationRule.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 | using Fetcharr.Models.Configuration.Radarr;
3 | using Fetcharr.Models.Configuration.Sonarr;
4 |
5 | namespace Fetcharr.Configuration.Validation.Rules
6 | {
7 | public class ServiceValidationRule : IValidationRule
8 | {
9 | public ValidationResult Validate(FetcharrConfiguration configuration)
10 | {
11 | foreach(KeyValuePair instance in configuration.Radarr)
12 | {
13 | if(string.IsNullOrEmpty(instance.Value.BaseUrl))
14 | {
15 | return new ValidationResult($"`radarr.{instance.Key}.base_url` must be set.");
16 | }
17 |
18 | if(string.IsNullOrEmpty(instance.Value.ApiKey))
19 | {
20 | return new ValidationResult($"`radarr.{instance.Key}.api_key` must be set.");
21 | }
22 | }
23 |
24 | foreach(KeyValuePair instance in configuration.Sonarr)
25 | {
26 | if(string.IsNullOrEmpty(instance.Value.BaseUrl))
27 | {
28 | return new ValidationResult($"`sonarr.{instance.Key}.base_url` must be set.");
29 | }
30 |
31 | if(string.IsNullOrEmpty(instance.Value.ApiKey))
32 | {
33 | return new ValidationResult($"`sonarr.{instance.Key}.api_key` must be set.");
34 | }
35 | }
36 |
37 | return ValidationResult.Success;
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/src/Testing/Layers/src/SonarrTestingLayer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Sonarr;
2 | using Fetcharr.Provider.Sonarr;
3 |
4 | using Microsoft.Extensions.Logging;
5 |
6 | using NSubstitute;
7 |
8 | namespace Fetcharr.Testing.Layers
9 | {
10 | ///
11 | /// Base layer for testing Radarr clients, , and related types.
12 | ///
13 | public class SonarrTestingLayer
14 | : BaseServiceTestingLayer
15 | {
16 | ///
17 | /// Creates an instance of ; optionally with a configuration.
18 | ///
19 | /// Configuration for the client. If not set, uses the default values.
20 | public SonarrClient CreateClient(FetcharrSonarrConfiguration? configuration = null)
21 | => this.CreateService(configuration ?? new FetcharrSonarrConfiguration());
22 |
23 | ///
24 | /// Creates a collection of instances; optionally with a list of configurations.
25 | ///
26 | /// List of configuration for the clients. If not set, creates an empty list.
27 | public SonarrClientCollection CreateClientCollection(IEnumerable? configurations = null)
28 | {
29 | IEnumerable clients = (configurations ?? [])
30 | .Select(v => this.CreateClient(v));
31 |
32 | return new SonarrClientCollection(clients, Substitute.For>());
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/src/Testing/Layers/src/RadarrTestingLayer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Radarr;
2 | using Fetcharr.Provider.Radarr;
3 |
4 | using Microsoft.Extensions.Logging;
5 |
6 | using NSubstitute;
7 |
8 | namespace Fetcharr.Testing.Layers
9 | {
10 | ///
11 | /// Base layer for testing Radarr clients, , and related types.
12 | ///
13 | public abstract class RadarrTestingLayer
14 | : BaseServiceTestingLayer
15 | {
16 | ///
17 | /// Creates an instance of ; optionally with a configuration.
18 | ///
19 | /// Configuration for the client. If not set, uses the default values.
20 | public RadarrClient CreateClient(FetcharrRadarrConfiguration? configuration = null)
21 | => this.CreateService(configuration ?? new FetcharrRadarrConfiguration());
22 |
23 | ///
24 | /// Creates a collection of instances; optionally with a list of configurations.
25 | ///
26 | /// List of configuration for the clients. If not set, creates an empty list.
27 | public RadarrClientCollection CreateClientCollection(IEnumerable? configurations = null)
28 | {
29 | IEnumerable clients = (configurations ?? [])
30 | .Select(v => this.CreateClient(v));
31 |
32 | return new RadarrClientCollection(clients, Substitute.For>());
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/docs/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: introduction
3 | sidebar_position: 1
4 | ---
5 |
6 | # Introduction
7 |
8 | Welcome to the Fetcharr documentation.
9 |
10 | ## Features
11 |
12 | - **Integrates with Sonarr and Radarr.** Easy setup with multiple Radarr and/or Sonarr instances.
13 | - **Customizable filter rules** to limit what instances can be used for what content.
14 | - **Cross-platform.** Works on `amd64`, `arm` and `arm64`.
15 | - **Frequently syncs your watchlist** allowing users to watch content shortly after adding it.
16 |
17 | ## Motivation
18 |
19 | One of the strongest motivations to make Fetcharr was to get a more customizable experience, than what other solutions could offer. Having one Sonarr/Radarr instance for English content and another for anime was the primary goal.
20 |
21 | ## Contributing
22 |
23 | Fetcharr is a simple hobby project. It is not meant to be anything more than that. If you have an interest in contributing, we'd love to have you on board!
24 |
25 | But, it should be noted that Fetcharr is nowhere near the first iteration of this concept. Below are some projects that Fetcharr took inspiration from, which you should consider helping, as well:
26 | - [Overseerr](https://github.com/sct/overseerr), by [Ryan Cohen](https://github.com/sct)
27 | - [Watchlistarr](https://github.com/nylonee/watchlistarr), by [Nihal Mirpuri](https://github.com/nylonee)
28 | - [Recyclarr](https://github.com/recyclarr/recyclarr), by [Robert Dailey](https://github.com/rcdailey)
29 |
30 | All of the projects above are made by very smart people, who have contributed hundreds of hours of their own free time. Consider helping them out instead, if you're interested in contributing to open-source.
31 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
3 | "name": ".NET (C#), Node.js (TypeScript) & NUKE",
4 | "dockerComposeFile": "docker-compose.yml",
5 | "service": "app",
6 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
7 | "overrideCommand": true,
8 | // Features to add to the dev container. More info: https://containers.dev/features.
9 | "features": {
10 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}
11 | },
12 | // Configure tool-specific properties.
13 | "customizations": {
14 | // Configure properties specific to VS Code.
15 | "vscode": {
16 | // Set *default* container specific settings.json values on container create.
17 | "settings": {},
18 | // Add the IDs of extensions you want installed when the container is created.
19 | "extensions": [
20 | "EditorConfig.editorconfig",
21 | "ms-dotnettools.csdevkit",
22 | "ms-dotnettools.vscodeintellicode-csharp",
23 | "redhat.vscode-yaml"
24 | ]
25 | }
26 | },
27 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
28 | "forwardPorts": [
29 | 3000,
30 | 8080
31 | ],
32 | "portsAttributes": {
33 | "3000": {
34 | "label": "Docusaurus",
35 | "protocol": "http",
36 | "onAutoForward": "ignore"
37 | },
38 | "8080": {
39 | "label": "Fetcharr API",
40 | "protocol": "http",
41 | "onAutoForward": "notify"
42 | }
43 | },
44 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
45 | "remoteUser": "root"
46 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/Queues/ITaskQueue.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.API.Pipeline
2 | {
3 | ///
4 | /// Contract for a task queue; consumed by background services and other async operations.
5 | ///
6 | /// Type of item to store in the queue.
7 | public interface ITaskQueue
8 | {
9 | ///
10 | /// Enqueue a new item onto the queue.
11 | ///
12 | /// Item to append onto the queue.
13 | ValueTask EnqueueAsync(TItem item, CancellationToken cancellationToken);
14 |
15 | ///
16 | /// Dequeue the oldest item off the queue and return it. If the queue is empty, this method is blocking.
17 | ///
18 | ValueTask DequeueAsync(CancellationToken cancellationToken);
19 |
20 | ///
21 | /// Dequeue items off the queue until it is empty or until items have been dequeued.
22 | ///
23 | /// Maximum amount of items to dequeue from the queue.
24 | /// -instance of, at most, items.
25 | IAsyncEnumerable DequeueRangeAsync(int max, CancellationToken cancellationToken);
26 |
27 | ///
28 | /// Dequeue all items off the queue and return them.
29 | ///
30 | /// -instance of all items within the queue.
31 | IAsyncEnumerable DequeueAllAsync(CancellationToken cancellationToken);
32 | }
33 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json
2 |
3 | name: ✨ Feature Request
4 | description: Suggest a feature
5 | labels: ['enhancement']
6 | body:
7 | - type: checkboxes
8 | id: terms
9 | attributes:
10 | label: Is there an existing issue for this?
11 | description: Please search to see if an existing issue already exists for your idea.
12 | options:
13 | - label: I have searched for existing issues.
14 | required: true
15 |
16 | - type: textarea
17 | id: description
18 | attributes:
19 | label: Description
20 | description: Describe your idea. If it's related to a problem, please provide a clear description of the problem, such as "It's frustrating that I can't do [...] when [...]"
21 | validations:
22 | required: true
23 |
24 | - type: textarea
25 | id: desired-behavior
26 | attributes:
27 | label: Desired Behavior
28 | description: Provide a clear description of the desired behaviour.
29 | validations:
30 | required: true
31 |
32 | - type: textarea
33 | id: additional-context
34 | attributes:
35 | label: Additional Context
36 | description: If you have any additional information or media which is relevant, please provide it here.
37 |
38 | - type: checkboxes
39 | id: accept-code-of-conduct
40 | attributes:
41 | label: Code of Conduct
42 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/fetcharr/fetcharr/blob/develop/CODE_OF_CONDUCT.md)
43 | options:
44 | - label: I have read and agree to follow Fetcharr's Code of Conduct
45 | required: true
--------------------------------------------------------------------------------
/src/Cache/Core/src/Extensions/IServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core.Services;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.Extensions.Logging;
5 | using Microsoft.Extensions.Options;
6 |
7 | namespace Fetcharr.Cache.Core.Extensions
8 | {
9 | public static partial class IServiceCollectionExtensions
10 | {
11 | ///
12 | /// Add caching to the given -instance.
13 | ///
14 | /// -instance to register caching providers onto.
15 | /// Action for configuring caching providers.
16 | public static IServiceCollection AddCaching(
17 | this IServiceCollection services,
18 | Action configure)
19 | {
20 | CachingProviderOptions options = new(services);
21 | configure(options);
22 |
23 | services.AddLogging(builder => builder.AddConsole());
24 | services.AddSingleton(Options.Create(options));
25 |
26 | services.AddHostedService();
27 | services.AddHostedService();
28 |
29 | return services;
30 | }
31 |
32 | ///
33 | /// Add caching to the given -instance.
34 | ///
35 | /// -instance to register caching providers onto.
36 | public static IServiceCollection AddCaching(this IServiceCollection services)
37 | => services.AddCaching(_ => { });
38 | }
39 | }
--------------------------------------------------------------------------------
/src/Configuration/src/EnvironmentVariables/EnvironmentVariableDeserializer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Configuration.EnvironmentVariables.Exceptions;
2 | using Fetcharr.Models;
3 |
4 | using YamlDotNet.Core;
5 | using YamlDotNet.Core.Events;
6 | using YamlDotNet.Serialization;
7 |
8 | namespace Fetcharr.Configuration.EnvironmentVariables
9 | {
10 | public record EnvironmentVariableValue;
11 |
12 | public class EnvironmentVariableDeserializer(
13 | IEnvironment environment)
14 | : INodeDeserializer
15 | {
16 | public bool Deserialize(
17 | IParser reader,
18 | Type expectedType,
19 | Func nestedObjectDeserializer,
20 | out object? value,
21 | ObjectDeserializer rootDeserializer)
22 | {
23 | if(expectedType != typeof(EnvironmentVariableValue))
24 | {
25 | value = null;
26 | return false;
27 | }
28 |
29 | Scalar scalar = reader.Consume();
30 | string[] segments = scalar.Value.Trim().Split(' ', count: 2);
31 |
32 | string environmentVariableName = segments[0];
33 | string? environmentVariableDefault = segments.ElementAtOrDefault(1)?.Trim();
34 | string? environmentVariableValue = environment.GetEnvironmentVariable(environmentVariableName);
35 |
36 | if(string.IsNullOrEmpty(environmentVariableValue))
37 | {
38 | environmentVariableValue = environmentVariableDefault;
39 | }
40 |
41 | value = environmentVariableValue ?? throw new EnvironmentVariableNotFoundException(environmentVariableName);
42 | return true;
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2 |
3 | name: Release and pre-release
4 |
5 | on:
6 | push:
7 | branches:
8 | - main
9 |
10 | jobs:
11 | release-linux:
12 | name: Build and release (Linux)
13 | runs-on: ubuntu-latest
14 | permissions:
15 | packages: write
16 | contents: write
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 |
23 | - name: Set up QEMU
24 | uses: docker/setup-qemu-action@v3
25 |
26 | - name: Set up Buildx
27 | uses: docker/setup-buildx-action@v3
28 |
29 | - name: Login to ghcr.io
30 | uses: docker/login-action@v3
31 | with:
32 | registry: ghcr.io
33 | username: ${{ github.actor }}
34 | password: ${{ secrets.GITHUB_TOKEN }}
35 |
36 | - name: Build and release
37 | run: ./build.cmd Release --push-image --include-integration-tests
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 |
41 | build-windows:
42 | name: Build and release (Windows)
43 | runs-on: windows-latest
44 | permissions:
45 | packages: write
46 | contents: write
47 | steps:
48 | - name: Checkout
49 | uses: actions/checkout@v4
50 | with:
51 | fetch-depth: 0
52 |
53 | - name: Login to ghcr.io
54 | uses: docker/login-action@v3
55 | with:
56 | registry: ghcr.io
57 | username: ${{ github.actor }}
58 | password: ${{ secrets.GITHUB_TOKEN }}
59 |
60 | - name: Build and release
61 | run: ./build.cmd Release --skip BuildImage
62 | env:
63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/src/Configuration/src/Secrets/SecretsProvider.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Frozen;
2 |
3 | using Fetcharr.Configuration.Parsing;
4 |
5 | using YamlDotNet.Serialization;
6 |
7 | namespace Fetcharr.Configuration.Secrets
8 | {
9 | ///
10 | /// Represents a provider for reading secrets from the secrets.yml configuration file.
11 | ///
12 | public interface ISecretsProvider
13 | {
14 | ///
15 | /// Gets all the secrets within the provider.
16 | ///
17 | FrozenDictionary Secrets { get; }
18 | }
19 |
20 | ///
21 | /// Default implementation of .
22 | ///
23 | public class SecretsProvider : ISecretsProvider
24 | {
25 | private readonly IConfigurationLocator _configurationLocator;
26 | private readonly Lazy> _secrets;
27 |
28 | public FrozenDictionary Secrets => this._secrets.Value.ToFrozenDictionary();
29 |
30 | public SecretsProvider(IConfigurationLocator configurationLocator)
31 | {
32 | this._configurationLocator = configurationLocator;
33 | this._secrets = new(this.LoadSecrets);
34 | }
35 |
36 | private Dictionary LoadSecrets()
37 | {
38 | FileInfo? secretsFile = this._configurationLocator.Get("secrets");
39 | if(secretsFile is null)
40 | {
41 | return [];
42 | }
43 |
44 | using StreamReader secretsStream = secretsFile.OpenText();
45 | return new DeserializerBuilder()
46 | .Build()
47 | .Deserialize>(secretsStream);
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Models/RadarrMovie.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Radarr;
2 |
3 | namespace Fetcharr.Provider.Radarr.Models
4 | {
5 | ///
6 | /// Representation of a Radarr movie.
7 | ///
8 | public class RadarrMovie
9 | {
10 | ///
11 | /// Gets or sets the ID of the movie, within Radarr.
12 | /// If , the movie does not yet exist in Radarr.
13 | ///
14 | public int? Id { get; set; }
15 |
16 | ///
17 | /// Gets or sets the title of the movie.
18 | ///
19 | public string Title { get; set; } = string.Empty;
20 |
21 | ///
22 | /// Gets or sets the release year of the movie.
23 | ///
24 | public int Year { get; set; }
25 |
26 | ///
27 | /// Gets or sets the status of the movie.
28 | ///
29 | public RadarrMovieStatus Status { get; set; }
30 |
31 | ///
32 | /// Gets or sets the certification of the movie.
33 | ///
34 | public string Certification { get; set; } = string.Empty;
35 |
36 | ///
37 | /// Gets or sets the genres of the movie.
38 | ///
39 | public List Genres { get; set; } = [];
40 |
41 | ///
42 | /// Gets or sets the folder to store the movie in.
43 | ///
44 | public string Folder { get; set; } = string.Empty;
45 |
46 | ///
47 | /// Gets whether this movie is still in production.
48 | ///
49 | public bool IsInProduction => this.Genres.Count == 0 || string.IsNullOrEmpty(this.Certification);
50 | }
51 | }
--------------------------------------------------------------------------------
/src/Cache/Hybrid/src/Extensions/CachingProviderOptionsExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 | using Fetcharr.Cache.InMemory.Extensions;
3 | using Fetcharr.Cache.SQLite.Extensions;
4 |
5 | using Microsoft.Extensions.DependencyInjection;
6 | using Microsoft.Extensions.Options;
7 |
8 | namespace Fetcharr.Cache.Hybrid.Extensions
9 | {
10 | public static partial class CachingProviderOptionsExtensions
11 | {
12 | ///
13 | /// Use the hybrid caching provider as an available cache.
14 | ///
15 | /// -instance to attach the provider onto.
16 | /// Identifiable name of the caching provider. Must be unique.
17 | public static CachingProviderOptions UseHybrid(this CachingProviderOptions options, string name)
18 | => options.UseHybrid(name, _ => { });
19 |
20 | ///
21 | ///
22 | public static CachingProviderOptions UseHybrid(
23 | this CachingProviderOptions options,
24 | string name,
25 | Action configure)
26 | {
27 | HybridCachingProviderOptions providerOptions = new(name);
28 | configure(providerOptions);
29 |
30 | options.UseInMemory(providerOptions.InMemory.Name, providerOptions.InMemory);
31 | options.UseSQLite(providerOptions.SQLite.Name, providerOptions.SQLite);
32 |
33 | options.Services.AddSingleton(_ => Options.Create(providerOptions));
34 | options.Services.AddSingleton();
35 | options.Services.AddKeyedSingleton(name);
36 |
37 | return options;
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/.github/workflows/development.yml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
2 |
3 | name: Development and PRs
4 |
5 | on:
6 | push:
7 | branches:
8 | - develop
9 | paths:
10 | - 'src/**'
11 | - '.build/**'
12 | - 'Dockerfile'
13 |
14 | pull_request:
15 | branches:
16 | - '*'
17 | paths:
18 | - 'src/**'
19 | - '.build/**'
20 | - 'Dockerfile'
21 | workflow_dispatch:
22 |
23 | jobs:
24 | build-linux:
25 | name: Build, format and test (Linux)
26 | runs-on: ubuntu-latest
27 | permissions:
28 | packages: write
29 | contents: write
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v4
33 | with:
34 | fetch-depth: 0
35 |
36 | - name: Set up QEMU
37 | uses: docker/setup-qemu-action@v3
38 |
39 | - name: Set up Buildx
40 | uses: docker/setup-buildx-action@v3
41 |
42 | - name: Login to ghcr.io
43 | uses: docker/login-action@v3
44 | with:
45 | registry: ghcr.io
46 | username: ${{ github.actor }}
47 | password: ${{ secrets.GITHUB_TOKEN }}
48 |
49 | - name: Build, format and test
50 | run: ./build.cmd Compile Format Test
51 |
52 | - name: Push development image
53 | if: ${{ github.ref == 'refs/heads/develop' }}
54 | run: ./build.cmd BuildImage --include-integration-tests --push-image
55 | env:
56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57 |
58 | build-windows:
59 | name: Build, format and test (Windows)
60 | runs-on: windows-latest
61 | permissions:
62 | packages: write
63 | contents: write
64 | steps:
65 | - name: Checkout
66 | uses: actions/checkout@v4
67 | with:
68 | fetch-depth: 0
69 |
70 | - name: Build, format and test
71 | run: ./build.cmd Compile Format Test
72 |
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrSeries.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Provider.Sonarr.Models
2 | {
3 | ///
4 | /// Representation of a Sonarr series.
5 | ///
6 | public class SonarrSeries
7 | {
8 | ///
9 | /// Gets or sets the ID of the series, within Sonarr.
10 | /// If , the series does not yet exist in Sonarr.
11 | ///
12 | public int? Id { get; set; }
13 |
14 | ///
15 | /// Gets or sets the title of the series.
16 | ///
17 | public string Title { get; set; } = string.Empty;
18 |
19 | ///
20 | /// Gets or sets the release year of the series.
21 | ///
22 | public int Year { get; set; }
23 |
24 | ///
25 | /// Gets or sets the status of the series.
26 | ///
27 | public SonarrSeriesStatus Status { get; set; }
28 |
29 | ///
30 | /// Gets or sets the certification of the series.
31 | ///
32 | public string Certification { get; set; } = string.Empty;
33 |
34 | ///
35 | /// Gets or sets the genres of the series.
36 | ///
37 | public List Genres { get; set; } = [];
38 |
39 | ///
40 | /// Gets or sets the folder to store the series in.
41 | ///
42 | public string Folder { get; set; } = string.Empty;
43 |
44 | ///
45 | /// Gets or sets some statistics about the series.
46 | ///
47 | public SonarrSeriesStatistics Statistics { get; set; } = new();
48 |
49 | ///
50 | /// Gets whether this series is still in production.
51 | ///
52 | public bool IsInProduction => this.Genres.Count == 0 || string.IsNullOrEmpty(this.Certification);
53 | }
54 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/Queues/BaseTaskQueue.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 | using System.Threading.Channels;
3 |
4 | namespace Fetcharr.API.Pipeline.Queues
5 | {
6 | ///
7 | /// Base class for a task queue.
8 | ///
9 | /// Type of item to store in the queue.
10 | public abstract class BaseTaskQueue
11 | : ITaskQueue
12 | {
13 | ///
14 | /// Gets or sets the -instance to contain the queue items.
15 | ///
16 | protected abstract Channel Queue { get; init; }
17 |
18 | ///
19 | public async ValueTask EnqueueAsync(TItem item, CancellationToken cancellationToken)
20 | {
21 | ArgumentNullException.ThrowIfNull(item, nameof(item));
22 |
23 | await this.Queue.Writer.WriteAsync(item, cancellationToken);
24 | }
25 |
26 | ///
27 | public async ValueTask DequeueAsync(
28 | CancellationToken cancellationToken)
29 | => await this.Queue.Reader.ReadAsync(cancellationToken);
30 |
31 | ///
32 | public async IAsyncEnumerable DequeueRangeAsync(
33 | int max, [EnumeratorCancellation] CancellationToken cancellationToken)
34 | {
35 | for(int i = 0; i < max; i++)
36 | {
37 | if(this.Queue.Reader.TryRead(out TItem? item))
38 | {
39 | yield return item;
40 | }
41 | else
42 | {
43 | break;
44 | }
45 | }
46 |
47 | await Task.CompletedTask;
48 | }
49 |
50 | ///
51 | public IAsyncEnumerable DequeueAllAsync(
52 | CancellationToken cancellationToken)
53 | => this.Queue.Reader.ReadAllAsync(cancellationToken);
54 | }
55 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/PlexClient.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 |
3 | using Fetcharr.Models.Configuration;
4 |
5 | using Flurl.Http;
6 |
7 | using Microsoft.Extensions.Options;
8 |
9 | namespace Fetcharr.Provider.Plex
10 | {
11 | ///
12 | /// Client for interacting with Plex.
13 | ///
14 | public class PlexClient(
15 | IOptions configuration,
16 | PlexMetadataClient metadataClient,
17 | PlexWatchlistClient watchlistClient,
18 | PlexFriendsWatchlistClient plexFriendsWatchlistClient)
19 | : ExternalProvider
20 | {
21 | private readonly FlurlClient _client =
22 | new FlurlClient("https://plex.tv/")
23 | .WithHeader("X-Plex-Token", configuration.Value.Plex.ApiToken)
24 | .WithHeader("X-Plex-Client-Identifier", "fetcharr");
25 |
26 | ///
27 | public override string ProviderName { get; } = "Plex";
28 |
29 | ///
30 | /// Gets the underlying client for interacting with metadata from Plex.
31 | ///
32 | public readonly PlexMetadataClient Metadata = metadataClient;
33 |
34 | ///
35 | /// Gets the underlying client for interacting with Plex watchlists.
36 | ///
37 | public readonly PlexWatchlistClient Watchlist = watchlistClient;
38 |
39 | ///
40 | /// Gets the underlying client for interacting with Plex watchlists for friends.
41 | ///
42 | public readonly PlexFriendsWatchlistClient FriendsWatchlistClient = plexFriendsWatchlistClient;
43 |
44 | ///
45 | public override async Task PingAsync(CancellationToken cancellationToken)
46 | {
47 | IFlurlResponse response = await this._client.Request("/api/v2/ping").GetAsync(cancellationToken: cancellationToken);
48 | return response.StatusCode == (int) HttpStatusCode.OK;
49 | }
50 | }
51 | }
--------------------------------------------------------------------------------
/src/Cache/InMemory/src/Extensions/CachingProviderOptionsExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 |
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.Extensions.Options;
5 |
6 | namespace Fetcharr.Cache.InMemory.Extensions
7 | {
8 | public static partial class CachingProviderOptionsExtensions
9 | {
10 | ///
11 | /// Use the in-memory caching provider as an available cache.
12 | ///
13 | /// -instance to attach the provider onto.
14 | /// Identifiable name of the caching provider. Must be unique.
15 | public static CachingProviderOptions UseInMemory(this CachingProviderOptions options, string name)
16 | => options.UseInMemory(name, _ => { });
17 |
18 | ///
19 | public static CachingProviderOptions UseInMemory(
20 | this CachingProviderOptions options,
21 | string name,
22 | InMemoryCachingProviderOptions providerOptions)
23 | {
24 | options.Services.AddSingleton(_ => Options.Create(providerOptions));
25 | options.Services.AddSingleton(sp => sp.GetRequiredKeyedService(name));
26 | options.Services.AddKeyedSingleton(name);
27 |
28 | return options;
29 | }
30 |
31 | ///
32 | public static CachingProviderOptions UseInMemory(
33 | this CachingProviderOptions options,
34 | string name,
35 | Action configure)
36 | {
37 | InMemoryCachingProviderOptions providerOptions = new(name);
38 | configure(providerOptions);
39 |
40 | return options.UseInMemory(name, providerOptions);
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/src/Cache/Core/src/Services/CacheEvictionService.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 | using Microsoft.Extensions.Hosting;
3 | using Microsoft.Extensions.Logging;
4 | using Microsoft.Extensions.Options;
5 |
6 | namespace Fetcharr.Cache.Core.Services
7 | {
8 | public class CacheEvictionService(
9 | ILogger logger,
10 | IServiceProvider services,
11 | IOptions options)
12 | : IHostedService
13 | , IDisposable
14 | {
15 | private Timer? timer { get; set; }
16 |
17 | public async Task StartAsync(CancellationToken cancellationToken)
18 | {
19 | logger.LogInformation("Starting cache eviction service.");
20 |
21 | this.timer = new Timer(
22 | callback: async _ => await this.EvictCachesAsync(cancellationToken),
23 | state: null,
24 | dueTime: options.Value.EvictionPeriod,
25 | period: options.Value.EvictionPeriod);
26 |
27 | await Task.CompletedTask;
28 | }
29 |
30 | public async Task StopAsync(CancellationToken cancellationToken)
31 | {
32 | logger.LogInformation("Stopping cache eviction service.");
33 | this.timer?.Change(Timeout.Infinite, 0);
34 |
35 | await Task.CompletedTask;
36 | }
37 |
38 | private async Task EvictCachesAsync(CancellationToken cancellationToken)
39 | {
40 | if(cancellationToken.IsCancellationRequested)
41 | {
42 | return;
43 | }
44 |
45 | logger.LogDebug("Evicting expired cache entries...");
46 |
47 | foreach(ICachingProvider provider in services.GetServices())
48 | {
49 | await provider.EvictExpiredAsync(cancellationToken);
50 | }
51 | }
52 |
53 | public void Dispose()
54 | {
55 | this.timer?.Dispose();
56 | GC.SuppressFinalize(this);
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/src/Provider.Radarr/src/Models/RadarrMovieOptions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Radarr;
2 |
3 | namespace Fetcharr.Provider.Radarr.Models
4 | {
5 | ///
6 | /// Options for adding a movie to Radarr.
7 | ///
8 | /// TMDB ID of the movie.
9 | public class RadarrMovieOptions(string tmdbId)
10 | {
11 | ///
12 | /// Gets or sets the IMDB ID of the movie.
13 | ///
14 | public string? ImdbID { get; set; }
15 |
16 | ///
17 | /// Gets or sets the TMDB ID of the movie.
18 | ///
19 | public string TmdbID { get; set; } = tmdbId;
20 |
21 | ///
22 | /// Gets or sets the root folder to place the movie in. If not set, defaults to instance default.
23 | ///
24 | public string? RootFolder { get; set; }
25 |
26 | ///
27 | /// Gets or sets the folder to place the movie in. If not set, defaults to instance default.
28 | ///
29 | public string? Folder { get; set; }
30 |
31 | ///
32 | /// Gets or sets the minimum availability required before attempting to fetch the movie.
33 | /// If not set, defaults to instance default.
34 | ///
35 | public RadarrMovieStatus? MinimumAvailability { get; set; }
36 |
37 | ///
38 | /// Gets or sets whether the movie should be monitored. If not set, defaults to instance default.
39 | ///
40 | public bool? Monitored { get; set; }
41 |
42 | ///
43 | /// Gets or sets which items to monitor, if the movie is monitored. If not set, defaults to instance default.
44 | ///
45 | public RadarrMonitoredItems? MonitoredItems { get; set; }
46 |
47 | ///
48 | /// Gets or sets the quality profile to assign to the movie. If not set, defaults to instance default.
49 | ///
50 | public string? QualityProfile { get; set; }
51 | }
52 | }
--------------------------------------------------------------------------------
/src/Provider.Plex/src/PlexMetadataClient.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 |
3 | using Fetcharr.Cache.Core;
4 | using Fetcharr.Models.Configuration;
5 | using Fetcharr.Provider.Plex.Models;
6 |
7 | using Flurl.Http;
8 | using Flurl.Http.Configuration;
9 |
10 | using Microsoft.Extensions.DependencyInjection;
11 | using Microsoft.Extensions.Options;
12 |
13 | namespace Fetcharr.Provider.Plex
14 | {
15 | ///
16 | /// Client for fetching metadata from Plex.
17 | ///
18 | public class PlexMetadataClient(
19 | IOptions configuration,
20 | [FromKeyedServices("metadata")] ICachingProvider cachingProvider)
21 | {
22 | private readonly FlurlClient _client =
23 | new FlurlClient("https://metadata.provider.plex.tv/library/metadata/")
24 | .WithHeader("X-Plex-Token", configuration.Value.Plex.ApiToken)
25 | .WithHeader("X-Plex-Client-Identifier", "fetcharr")
26 | .WithSettings(opts => opts.JsonSerializer = new DefaultJsonSerializer(new JsonSerializerOptions
27 | {
28 | // Metadata endpoints send back an object with both 'guid' and 'Guid' keys,
29 | // so we need to explicitly name all properties within the models...
30 | PropertyNameCaseInsensitive = false,
31 | }));
32 |
33 | ///
34 | /// Gets metadata from Plex for an item, given it's rating key, .
35 | ///
36 | public async Task GetMetadataFromRatingKeyAsync(string ratingKey)
37 | {
38 | MediaResponse metadata = await cachingProvider.GetAsync>(
39 | ratingKey,
40 | async () => await this._client
41 | .Request(ratingKey)
42 | .AppendQueryParam("format", "json")
43 | .GetJsonAsync>());
44 |
45 | return metadata.MediaContainer.Metadata?.FirstOrDefault();
46 | }
47 | }
48 | }
--------------------------------------------------------------------------------
/src/Cache/SQLite/src/Extensions/CachingProviderOptionsExtensions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Cache.Core;
2 | using Fetcharr.Cache.SQLite.Contexts;
3 |
4 | using Microsoft.Extensions.DependencyInjection;
5 | using Microsoft.Extensions.Options;
6 |
7 | namespace Fetcharr.Cache.SQLite.Extensions
8 | {
9 | public static partial class CachingProviderOptionsExtensions
10 | {
11 | ///
12 | /// Use the SQLite caching provider as an available cache.
13 | ///
14 | /// -instance to attach the provider onto.
15 | /// Identifiable name of the caching provider. Must be unique.
16 | public static CachingProviderOptions UseSQLite(this CachingProviderOptions options, string name)
17 | => options.UseSQLite(name, _ => { });
18 |
19 | ///
20 | public static CachingProviderOptions UseSQLite(
21 | this CachingProviderOptions options,
22 | string name,
23 | SQLiteCachingProviderOptions providerOptions)
24 | {
25 | options.Services.AddSingleton(_ => Options.Create(providerOptions));
26 | options.Services.AddSingleton(sp => sp.GetRequiredKeyedService(name));
27 | options.Services.AddKeyedSingleton(name);
28 |
29 | options.Services.AddDbContextFactory();
30 |
31 | return options;
32 | }
33 |
34 | ///
35 | /// Action for configuring the provider.
36 | public static CachingProviderOptions UseSQLite(
37 | this CachingProviderOptions options,
38 | string name,
39 | Action configure)
40 | {
41 | SQLiteCachingProviderOptions providerOptions = new(name);
42 | configure(providerOptions);
43 |
44 | return options.UseSQLite(name, providerOptions);
45 | }
46 | }
47 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/Queues/BaseUniqueTaskQueue.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 |
3 | namespace Fetcharr.API.Pipeline.Queues
4 | {
5 | ///
6 | /// Base class for a set queue of tasks, i.e. queue with only unique elements.
7 | ///
8 | ///
9 | /// Since the queue uses under the hood, order of the queue cannot be guranteed.
10 | ///
11 | /// Type of item to store in the queue.
12 | public abstract class BaseUniqueTaskQueue
13 | : ITaskQueue
14 | {
15 | private readonly ConcurrentSetQueue _queue = [];
16 |
17 | public async ValueTask EnqueueAsync(TItem item, CancellationToken cancellationToken)
18 | {
19 | ArgumentNullException.ThrowIfNull(item, nameof(item));
20 |
21 | this._queue.Enqueue(item);
22 |
23 | await Task.CompletedTask;
24 | }
25 |
26 | ///
27 | public async ValueTask DequeueAsync(
28 | CancellationToken cancellationToken)
29 | => await ValueTask.FromResult(this._queue.Dequeue());
30 |
31 | ///
32 | public async IAsyncEnumerable DequeueRangeAsync(
33 | int max, [EnumeratorCancellation] CancellationToken cancellationToken)
34 | {
35 | for(int i = 0; i < max; i++)
36 | {
37 | if(this._queue.TryDequeue(out TItem? item))
38 | {
39 | yield return item;
40 | }
41 | else
42 | {
43 | break;
44 | }
45 | }
46 |
47 | await Task.CompletedTask;
48 | }
49 |
50 | ///
51 | public async IAsyncEnumerable DequeueAllAsync(
52 | [EnumeratorCancellation] CancellationToken cancellationToken)
53 | {
54 | foreach(TItem item in this._queue.DequeueAll())
55 | {
56 | yield return item;
57 | }
58 |
59 | await Task.CompletedTask;
60 | }
61 | }
62 | }
--------------------------------------------------------------------------------
/src/API/src/Pipeline/BasePeriodicService.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.API.Pipeline
2 | {
3 | ///
4 | /// Base for timed background services, using periodic intervals.
5 | ///
6 | /// Period of time between invocations.
7 | public abstract class BasePeriodicService(TimeSpan period, ILogger logger)
8 | : BackgroundService
9 | {
10 | ///
11 | /// Gets the highest about of time between each period, when the service is doing back-off.
12 | ///
13 | private readonly TimeSpan _periodHighLimit = period * 60;
14 |
15 | ///
16 | /// Gets the multiplier for the service period, for each time the service fails or crashes.
17 | ///
18 | private readonly float _periodBackoffMultiplier = 1.2f;
19 |
20 | protected override sealed async Task ExecuteAsync(CancellationToken cancellationToken)
21 | {
22 | using PeriodicTimer timer = new(period);
23 |
24 | while(await timer.WaitForNextTickAsync(cancellationToken))
25 | {
26 | if(cancellationToken.IsCancellationRequested)
27 | {
28 | break;
29 | }
30 |
31 | try
32 | {
33 | await this.InvokeAsync(cancellationToken);
34 | }
35 | catch(Exception ex)
36 | {
37 | timer.Period *= this._periodBackoffMultiplier;
38 |
39 | if(timer.Period > this._periodHighLimit)
40 | {
41 | timer.Period = this._periodHighLimit;
42 | }
43 |
44 | logger.LogError(
45 | ex,
46 | "Failed to finish periodic task. Increasing back-off to {BackoffTime}.",
47 | timer.Period.ToString("c"));
48 | }
49 |
50 | timer.Period = period;
51 | }
52 | }
53 |
54 | ///
55 | /// Callback to invoke every timer interval.
56 | ///
57 | public abstract Task InvokeAsync(CancellationToken cancellationToken);
58 | }
59 | }
--------------------------------------------------------------------------------
/docs/docs/configuration/config_examples.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: config-examples
3 | sidebar_position: 5
4 | ---
5 |
6 | # Configuration Examples
7 |
8 | This page lists various scenarios supported by Fetcharr, which might come in handy:
9 |
10 | ## Limit an instance to anime-only
11 |
12 | This method functions on both Radarr and Sonarr instances. It separates anime content from other content and sends them to different instances. This can help prevent clutter in your Sonarr and/or Radarr instances.
13 |
14 | ```yaml title="fetcharr.yaml"
15 | sonarr:
16 | default:
17 | base_url: http://localhost:8989
18 | api_key: SOME_KEY
19 | enabled: true
20 |
21 | root_folder: /mnt/TV Shows
22 | series_type: Standard
23 |
24 | anime_only:
25 | base_url: http://localhost:8990
26 | api_key: SOME_KEY
27 | enabled: true
28 |
29 | filters:
30 | genre:
31 | - anime
32 |
33 | root_folder: /mnt/TV Shows (Anime)
34 | series_type: Anime
35 | ```
36 |
37 | Fetcharr will prefer the instance *explicitly* allows anime, as opposed to the instance which *implicitly* allows anime.
38 |
39 | ## Limit an instance to kids content
40 |
41 | This method functions on both Radarr and Sonarr instances. It separates content for children into a separate instance, allowing for less clutter. It functions very similar to [anime-only instances](#limit-an-instance-to-anime-only).
42 |
43 | ```yaml title="fetcharr.yaml"
44 | sonarr:
45 | default:
46 | base_url: http://localhost:8989
47 | api_key: SOME_KEY
48 | enabled: true
49 |
50 | root_folder: /mnt/TV Shows
51 |
52 | anime_only:
53 | base_url: http://localhost:8990
54 | api_key: SOME_KEY
55 | enabled: true
56 |
57 | filters:
58 | certification:
59 | # TV Parental Guidelines (US)
60 | - TV-Y # All children, including ages from 2-6
61 | - TV-Y7 # Directed at children age 7 and above.
62 | - TV-G # General audience
63 | - TV-PG # Parental guidance suggested
64 |
65 | # MPA film rating systems (US)
66 | - G # General audience; all ages admitted.
67 | - PG # Parental guidance suggested; some material may not be suitable for children.
68 |
69 | root_folder: /mnt/TV Shows (Kids)
70 | ```
--------------------------------------------------------------------------------
/src/Provider.Plex/src/Models/Metadata/PlexMetadataItem.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Fetcharr.Provider.Plex.Models
4 | {
5 | ///
6 | /// Representation of a single Plex metadata item.
7 | ///
8 | public class PlexMetadataItem
9 | {
10 | ///
11 | /// Gets or sets the title of the item.
12 | ///
13 | [JsonPropertyName("title")]
14 | public string Title { get; set; } = string.Empty;
15 |
16 | [JsonPropertyName("originalTitle")]
17 | public string OriginalTitle { get; set; } = string.Empty;
18 |
19 | [JsonPropertyName("ratingKey")]
20 | public string RatingKey { get; set; } = string.Empty;
21 |
22 | [JsonPropertyName("type")]
23 | public string Type { get; set; } = string.Empty;
24 |
25 | [JsonPropertyName("duration")]
26 | public long Duration { get; set; }
27 |
28 | [JsonPropertyName("year")]
29 | public int Year { get; set; }
30 |
31 | [JsonPropertyName("Genre")]
32 | public List Genre { get; set; } = [];
33 |
34 | [JsonPropertyName("Guid")]
35 | public List Guid { get; set; } = [];
36 |
37 | ///
38 | /// Gets whether this item is considered to be anime.
39 | ///
40 | public bool IsAnime => this.Genre.Any(v => v.Slug == "anime");
41 |
42 | ///
43 | /// Gets the IMDB ID of the item, if available.
44 | ///
45 | public string? ImdbId =>
46 | this.Guid
47 | .FirstOrDefault(v => v.Id.StartsWith("imdb"))?.Id
48 | .Split("//")[^1];
49 |
50 | ///
51 | /// Gets the TVDB ID of the item, if available.
52 | ///
53 | public string? TvdbId =>
54 | this.Guid
55 | .FirstOrDefault(v => v.Id.StartsWith("tvdb"))?.Id
56 | .Split("//")[^1];
57 |
58 | ///
59 | /// Gets the TMDB ID of the item, if available.
60 | ///
61 | public string? TmdbId =>
62 | this.Guid
63 | .FirstOrDefault(v => v.Id.StartsWith("tmdb"))?.Id
64 | .Split("//")[^1];
65 | }
66 | }
--------------------------------------------------------------------------------
/src/API/src/Program.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.API.Extensions;
2 | using Fetcharr.Cache.Core.Extensions;
3 | using Fetcharr.Cache.Hybrid.Extensions;
4 | using Fetcharr.Cache.InMemory.Extensions;
5 | using Fetcharr.Models.Configuration;
6 |
7 | using Serilog;
8 | using Serilog.Events;
9 |
10 | namespace Fetcharr.API
11 | {
12 | class Program
13 | {
14 | static async Task Main(string[] args)
15 | {
16 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
17 |
18 | builder.Host.UseSerilog((context, serviceProvider, configuration) =>
19 | {
20 | IAppDataSetup appDataSetup = serviceProvider.GetRequiredService();
21 |
22 | configuration.MinimumLevel.Warning();
23 | configuration.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error);
24 | configuration.MinimumLevel.Override("Microsoft.Hosting", LogEventLevel.Information);
25 | configuration.MinimumLevel.Override("Fetcharr", LogEventLevel.Information);
26 |
27 | if(context.HostingEnvironment.IsDevelopment())
28 | {
29 | configuration.MinimumLevel.Information();
30 | configuration.MinimumLevel.Override("Fetcharr", LogEventLevel.Verbose);
31 | configuration.MinimumLevel.Override("Fetcharr.Cache", LogEventLevel.Information);
32 | }
33 |
34 | configuration.WriteTo.Console();
35 |
36 | if(context.HostingEnvironment.IsProduction())
37 | {
38 | configuration.WriteTo.File(
39 | $"{appDataSetup.LogDirectory}/fetcharr.log",
40 | rollingInterval: RollingInterval.Day);
41 | }
42 | });
43 |
44 | builder.Services.AddCaching(opts => opts
45 | .UseHybrid("metadata", opts => opts.SQLite.DatabasePath = "metadata.sqlite")
46 | .UseInMemory("watchlist")
47 | .UseInMemory("plex-graphql"));
48 |
49 | builder.Services.AddFetcharr();
50 | builder.Services.AddControllers();
51 |
52 | WebApplication app = builder.Build();
53 |
54 | app.MapControllers();
55 |
56 | await app.RunAsync();
57 | }
58 | }
59 | }
--------------------------------------------------------------------------------
/src/Provider.Sonarr/src/Models/SonarrSeriesOptions.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration.Sonarr;
2 |
3 | namespace Fetcharr.Provider.Sonarr.Models
4 | {
5 | ///
6 | /// Options for adding a series to Sonarr.
7 | ///
8 | /// TVDB ID of the series.
9 | public class SonarrSeriesOptions(string tvdbId)
10 | {
11 | ///
12 | /// Gets or sets the TVDB ID of the series.
13 | ///
14 | public string TvdbID { get; set; } = tvdbId;
15 |
16 | ///
17 | /// Gets or sets the root folder to place the series in. If not set, defaults to instance default.
18 | ///
19 | public string? RootFolder { get; set; }
20 |
21 | ///
22 | /// Gets or sets the folder to place the series in. If not set, defaults to instance default.
23 | ///
24 | public string? Folder { get; set; }
25 |
26 | ///
27 | /// Gets or sets the type of the series. If not set, defaults to instance default.
28 | ///
29 | public SonarrSeriesType? SeriesType { get; set; }
30 |
31 | ///
32 | /// Gets or sets whether to place seasons in their own folders. If not set, defaults to instance default.
33 | ///
34 | public bool? SeasonFolder { get; set; }
35 |
36 | ///
37 | /// Gets or sets whether the series should be monitored. If not set, defaults to instance default.
38 | ///
39 | public bool? Monitored { get; set; }
40 |
41 | ///
42 | /// Gets or sets whether to monitor new seasons in the series. If not set, defaults to instance default.
43 | ///
44 | public bool? MonitorNewItems { get; set; }
45 |
46 | ///
47 | /// Gets or sets the quality profile to assign to the series. If not set, defaults to instance default.
48 | ///
49 | public string? QualityProfile { get; set; }
50 |
51 | ///
52 | /// Gets or sets which items to monitor, if the series is monitored. If not set, defaults to instance default.
53 | ///
54 | public SonarrMonitoredItems? MonitoredItems { get; set; }
55 | }
56 | }
--------------------------------------------------------------------------------
/src/API/src/Services/RadarrMovieService.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.API.Pipeline;
2 | using Fetcharr.API.Pipeline.Queues;
3 | using Fetcharr.Provider.Plex.Models;
4 | using Fetcharr.Provider.Radarr;
5 | using Fetcharr.Provider.Radarr.Models;
6 |
7 | namespace Fetcharr.API.Services
8 | {
9 | ///
10 | /// Queue for storing Radarr movie requests, to be processed by .
11 | ///
12 | public class RadarrMovieQueue()
13 | : BaseUniqueTaskQueue()
14 | {
15 |
16 | }
17 |
18 | ///
19 | /// Hosted service for receiving items from the Plex watchlist and sending them to Radarr.
20 | ///
21 | public class RadarrMovieService(
22 | RadarrMovieQueue radarrMovieQueue,
23 | RadarrClientCollection radarrClientCollection,
24 | ILogger logger)
25 | : BasePeriodicService(TimeSpan.FromSeconds(20), logger)
26 | {
27 | public override async Task InvokeAsync(CancellationToken cancellationToken)
28 | {
29 | await foreach(PlexMetadataItem item in radarrMovieQueue.DequeueRangeAsync(max: 20, cancellationToken))
30 | {
31 | if(item.TmdbId is null)
32 | {
33 | logger.LogError("Movie '{Title} ({Year})' has no TMDB ID.", item.Title, item.Year);
34 | continue;
35 | }
36 |
37 | logger.LogDebug("Sending movie '{Title} ({Year})' to Radarr...", item.Title, item.Year);
38 |
39 | RadarrMovie? movie = await radarrClientCollection.GetMovieByTmdbAsync(item.TmdbId);
40 | if(movie is null)
41 | {
42 | logger.LogError("Movie '{Title} ({Year})' could not be found on Radarr.", item.Title, item.Year);
43 | continue;
44 | }
45 |
46 | RadarrClient? client = radarrClientCollection.FindAppropriateClient(movie);
47 | if(client is null)
48 | {
49 | logger.LogError("Could not find appropriate Radarr instance for movie '{Title} ({Year})'.", item.Title, item.Year);
50 | continue;
51 | }
52 |
53 | await client.AddMovieAsync(new RadarrMovieOptions(item.TmdbId));
54 | }
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/docs/docs/configuration/schema-validation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: schema-validation
3 | sidebar_position: 3
4 | ---
5 |
6 | # Schema Validation
7 |
8 | All of the YAML files in Fetcharr have accompanying schema files. This helps narrow down issues before trying to deploy them, only to learn that you made a typo. On top of that, it is also useful if:
9 | - you edit your YAML files in [Visual Studio Code](https://code.visualstudio.com/),
10 | - you want in-editor documentation about the various properties and what they support,
11 | - you want auto-complete for properties and objects
12 | - or you want to avoid trivial errors, such as making a typo.
13 |
14 | :::info
15 |
16 | *Schema files* help you validate that your YAML is both valid YAML, as well as a valid configuration. This makes debugging a lot easier, since you'll see formatting errors immediately, instead of when you start Fetcharr.
17 |
18 | Schemas are JSON files, which can be stored on any number of services, or even local. [JSON Schema Store](https://schemastore.org/) is one such service, hosting hundreds of schema files for various different configuration files; not just YAML!
19 |
20 | :::
21 |
22 | ## YAML Validation in Visual Studio Code
23 |
24 | For this, we assume that you already have Visual Studio Code up and running on your local machine. Afterwards, install the [YAML Extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) by [Red Hat](https://marketplace.visualstudio.com/publishers/redhat) in Visual Studio Code.
25 |
26 | :::info
27 |
28 | If you don't know how to install extensions in VS Code, you can [read this page](https://code.visualstudio.com/docs/editor/extension-marketplace) or [watch this video](https://code.visualstudio.com/learn/get-started/extensions).
29 |
30 | :::
31 |
32 | ### Enable schema validation
33 |
34 | If you're using the configuration files from Fetcharr, they should already have schema validation enabled. If not, you can add this to the top of the file:
35 |
36 | ```yaml
37 | # yaml-language-server: $schema=https://raw.githubusercontent.com/fetcharr/fetcharr/main/config-schema.json
38 | ```
39 |
40 | This will tell the YAML extension which schema to use and where to find it. After adding it, you should begin to see contextual information about the configuration file, when you hover over properties. If you make an indentation error or typo, you should also be able to see it now.
--------------------------------------------------------------------------------
/src/Configuration/src/Parsing/ConfigurationLocator.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | namespace Fetcharr.Configuration.Parsing
4 | {
5 | ///
6 | /// Defines a mechanism for locating configuration files.
7 | ///
8 | public interface IConfigurationLocator
9 | {
10 | ///
11 | /// Locates the configuration file of name and returns it, if found; otherwise,
12 | ///
13 | /// Name of the configuration file. Can be with or without extension.
14 | FileInfo? Get(string name);
15 |
16 | ///
17 | /// Locate and return all files within the default, or configured, configuration directory.
18 | ///
19 | /// Thrown if the configuration directory is unreachable or doesn't exist.
20 | IEnumerable GetAll();
21 | }
22 |
23 | ///
24 | /// Default implementation of the interface.
25 | ///
26 | public class ConfigurationLocator(
27 | IAppDataSetup appDataSetup)
28 | : IConfigurationLocator
29 | {
30 | private readonly string[] _configSearchPatterns = ["*.yml", "*.yaml"];
31 |
32 | ///
33 | public FileInfo? Get(string name)
34 | {
35 | name = Path.GetFileNameWithoutExtension(name);
36 |
37 | foreach(FileInfo file in this.GetAll())
38 | {
39 | if(Path.GetFileNameWithoutExtension(file.Name).Equals(name, StringComparison.InvariantCultureIgnoreCase))
40 | {
41 | return file;
42 | }
43 | }
44 |
45 | return null;
46 | }
47 |
48 | ///
49 | public IEnumerable GetAll()
50 | {
51 | DirectoryInfo directory = new(appDataSetup.ConfigDirectory);
52 | if(!directory.Exists)
53 | {
54 | throw new DirectoryNotFoundException($"Configuration directory could not be found: '{appDataSetup.ConfigDirectory}'");
55 | }
56 |
57 | return this._configSearchPatterns.SelectMany(directory.EnumerateFiles);
58 | }
59 | }
60 | }
--------------------------------------------------------------------------------
/src/API/src/Services/SonarrSeriesService.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.API.Pipeline;
2 | using Fetcharr.API.Pipeline.Queues;
3 | using Fetcharr.Provider.Plex.Models;
4 | using Fetcharr.Provider.Sonarr;
5 | using Fetcharr.Provider.Sonarr.Models;
6 |
7 | namespace Fetcharr.API.Services
8 | {
9 | ///
10 | /// Queue for storing Sonarr series requests, to be processed by .
11 | ///
12 | public class SonarrSeriesQueue()
13 | : BaseUniqueTaskQueue()
14 | {
15 |
16 | }
17 |
18 | ///
19 | /// Hosted service for receiving items from the Plex watchlist and sending them to Sonarr.
20 | ///
21 | public class SonarrSeriesService(
22 | SonarrSeriesQueue sonarrSeriesQueue,
23 | SonarrClientCollection sonarrClientCollection,
24 | ILogger logger)
25 | : BasePeriodicService(TimeSpan.FromSeconds(20), logger)
26 | {
27 | public override async Task InvokeAsync(CancellationToken cancellationToken)
28 | {
29 | await foreach(PlexMetadataItem item in sonarrSeriesQueue.DequeueRangeAsync(max: 20, cancellationToken))
30 | {
31 | if(item.TvdbId is null)
32 | {
33 | logger.LogError("Series '{Title} ({Year})' has no TVDB ID.", item.Title, item.Year);
34 | continue;
35 | }
36 |
37 | logger.LogDebug("Sending series '{Title} ({Year})' to Sonarr...", item.Title, item.Year);
38 |
39 | SonarrSeries? series = await sonarrClientCollection.GetSeriesByTvdbAsync(item.TvdbId);
40 | if(series is null)
41 | {
42 | logger.LogError("Series '{Title} ({Year})' could not be found on Sonarr.", item.Title, item.Year);
43 | continue;
44 | }
45 |
46 | SonarrClient? client = sonarrClientCollection.FindAppropriateClient(series);
47 | if(client is null)
48 | {
49 | logger.LogError("Could not find appropriate Sonarr instance for series '{Title} ({Year})'.", item.Title, item.Year);
50 | continue;
51 | }
52 |
53 | await client.AddSeriesAsync(new SonarrSeriesOptions(item.TvdbId));
54 | }
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/src/Configuration/src/Parsing/ConfigurationMerger.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 |
3 | using Newtonsoft.Json;
4 | using Newtonsoft.Json.Linq;
5 |
6 | using YamlDotNet.Serialization;
7 |
8 | namespace Fetcharr.Configuration.Parsing
9 | {
10 | ///
11 | /// Represents a merger for configuration files.
12 | ///
13 | /// Instance of the YAML deserializer to use.
14 | public class ConfigurationMerger(IDeserializer deserializer)
15 | {
16 | private readonly List _configs = [];
17 |
18 | ///
19 | /// Add a partial configuration file to the merger.
20 | ///
21 | public ConfigurationMerger AddConfig(FileInfo file)
22 | {
23 | using StreamReader content = file.OpenText();
24 | return this.AddConfig(content.ReadToEnd());
25 | }
26 |
27 | ///
28 | /// Add partial configuration file YAML to the merger.
29 | ///
30 | public ConfigurationMerger AddConfig(string yaml)
31 | => this.AddConfig(deserializer.Deserialize(yaml));
32 |
33 | ///
34 | /// Add a partial -instance to the merger.
35 | ///
36 | public ConfigurationMerger AddConfig(FetcharrConfiguration config)
37 | {
38 | this._configs.Add(config);
39 | return this;
40 | }
41 |
42 | ///
43 | /// Merge all the added configurations into a single -instance.
44 | ///
45 | public FetcharrConfiguration Merge()
46 | {
47 | JObject destination = [];
48 |
49 | JsonMergeSettings mergeSettings = new()
50 | {
51 | MergeArrayHandling = MergeArrayHandling.Union,
52 | MergeNullValueHandling = MergeNullValueHandling.Merge,
53 | };
54 |
55 | foreach(FetcharrConfiguration configuration in this._configs)
56 | {
57 | JObject configObject = JObject.Parse(JsonConvert.SerializeObject(configuration));
58 |
59 | destination.Merge(configObject, mergeSettings);
60 | }
61 |
62 | return destination.ToObject()!;
63 | }
64 | }
65 | }
--------------------------------------------------------------------------------
/docs/src/css/custom.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --ifm-color-primary: #be93fa;
3 | --ifm-color-primary-dark: #9e69e2;
4 | --ifm-color-primary-darker: #8e4fd2;
5 | --ifm-color-primary-darkest: #7038ba;
6 | --ifm-color-primary-light: #29d5b0;
7 | --ifm-color-primary-lighter: #32d8b4;
8 | --ifm-color-primary-lightest: #4fddbf;
9 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
10 |
11 | --ifm-background-color: #101216 !important;
12 |
13 | --ifm-color-info-dark: #8BE9FD !important;
14 | --ifm-color-info-contrast-background: rgb(31, 37, 46) !important;
15 | --ifm-color-warning-dark: #FFB86C !important;
16 | --ifm-color-warning-contrast-background: rgb(37, 28, 24) !important;
17 | --ifm-color-danger-dark: #FF5555 !important;
18 | --ifm-color-danger-contrast-background: rgb(46, 24, 24) !important;
19 |
20 | --ifm-menu-color: #dcdbdf !important;
21 | --ifm-menu-color-active: #be93fa !important;
22 | --ifm-menu-color-background-active: rgba(255, 255, 255, 0.05) !important;
23 |
24 | --ifm-breadcrumb-color-active: #be93fa !important;
25 | --ifm-breadcrumb-item-background-active: rgba(255, 255, 255, 0.05) !important;
26 |
27 | --ifm-link-color: #be93fa !important;
28 | }
29 |
30 | .header-github-link:hover {
31 | opacity: 0.6;
32 | }
33 |
34 | .header-github-link:before {
35 | content: '';
36 | width: 24px;
37 | height: 24px;
38 | display: flex;
39 | background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
40 | }
41 |
42 | code {
43 | color: rgb(232, 230, 227);
44 | background-color: rgb(39, 41, 44);
45 | padding: 0 4px;
46 | border-radius: 4px;
47 | border: 0;
48 | }
49 |
50 | li[role='tab'] {
51 | padding-top: 8px;
52 | padding-bottom: 8px;
53 | }
--------------------------------------------------------------------------------
/.build/Build.Environment.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.CI.GitHubActions;
3 | using Nuke.Common.Git;
4 | using Nuke.Common.IO;
5 | using Nuke.Common.Tools.GitVersion;
6 |
7 | partial class Build : NukeBuild
8 | {
9 | private const string RepositoryUrl = "https://github.com/fetcharr/fetcharr";
10 |
11 | private const string RepositoryDescription = "Automatically sync Plex watchlist to your Sonarr and Radarr instances.";
12 |
13 | private const string RepositoryLicense = "MIT";
14 |
15 | private const string DockerImage = "ghcr.io/fetcharr/fetcharr";
16 |
17 | private AbsolutePath SourceDirectory => RootDirectory / "src";
18 |
19 | private AbsolutePath SolutionFilePath => SourceDirectory / "Fetcharr.sln";
20 |
21 | private AbsolutePath ApiProjectDirectory => SourceDirectory / "API" / "src";
22 |
23 | private AbsolutePath AssetsDirectory => RootDirectory / "assets";
24 |
25 | private AbsolutePath PublishOutputDirectory => AssetsDirectory / "publish";
26 |
27 | [GitVersion(Framework = "net8.0", NoFetch = true)]
28 | readonly GitVersion GitVersion;
29 |
30 | [GitRepository]
31 | readonly GitRepository Repository;
32 |
33 | GitHubActions GitHubActions => GitHubActions.Instance;
34 |
35 | ///
36 | /// Gets whether NUKE is building a release build or not.
37 | ///
38 | private bool IsReleaseBuild => GitVersion.BranchName.Equals("main", StringComparison.InvariantCultureIgnoreCase);
39 |
40 | ///
41 | /// Gets the version tag for the current build, with release version numbering.
42 | ///
43 | private string ReleaseVersionTag => GitVersion.MajorMinorPatch;
44 |
45 | ///
46 | /// Gets the version tag for the current build, with development version numbering.
47 | ///
48 | private string DevelopmentVersionTag => $"develop-{GitVersion.MajorMinorPatch}.{GitVersion.PreReleaseNumber}";
49 |
50 | ///
51 | /// Gets the primary version tag for the current version.
52 | ///
53 | private string VersionTag => this.IsReleaseBuild
54 | ? ReleaseVersionTag
55 | : DevelopmentVersionTag;
56 |
57 | ///
58 | /// Gets the version tags for the current version.
59 | ///
60 | private string[] VersionTags => this.IsReleaseBuild
61 | ? ["latest", $"{GitVersion.Major}", $"{GitVersion.Major}.{GitVersion.Minor}", ReleaseVersionTag]
62 | : ["develop", DevelopmentVersionTag];
63 | }
--------------------------------------------------------------------------------
/docs/docs/getting-started/installation.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | id: installation
3 | sidebar_position: 1
4 | ---
5 |
6 | import Tabs from '@theme/Tabs';
7 | import TabItem from '@theme/TabItem';
8 |
9 | # Installation
10 |
11 | Lists all the available options for installing/building Fetcharr. Some of these might not be supported by us, but we might still be able to help!
12 |
13 | :::warning
14 |
15 | **Fetcharr is still early in development.** If you would like to help test the bleeding edge, please use the image `ghcr.io/fetcharr/fetcharr:develop`!
16 |
17 | :::
18 |
19 | ## Docker
20 |
21 | :::warning
22 |
23 | Be sure to replace `/path/to/appdata/config` with a valid host directory path. If the path is invalid, Fetcharr will not be able to start the container properly.
24 |
25 | :::
26 |
27 |
28 |
29 | To start Fetcharr with Docker CLI, run the following command:
30 |
31 | ```bash
32 | docker run -d \
33 | --name fetcharr \
34 | -e TZ=Europe/Copenhagen \
35 | -v /path/to/appdata/config:/config \
36 | --restart unless-stopped \
37 | ghcr.io/fetcharr/fetcharr:latest
38 | ```
39 |
40 |
41 | For a declarative approach to starting Fetcharr, you can use Docker Compose, like so:
42 |
43 | ```yaml title="compose.yaml"
44 | services:
45 | fetcharr:
46 | image: ghcr.io/fetcharr/fetcharr:latest
47 | container_name: fetcharr
48 | environment:
49 | - TZ=Europe/Copenhagen
50 | volumes:
51 | - /path/to/appdata/config:/config
52 | restart: unless-stopped
53 | ```
54 |
55 | Then, to start Fetcharr:
56 |
57 | ```bash
58 | docker-compose up -d
59 | ```
60 |
61 |
62 |
63 | ## From source
64 |
65 | :::danger
66 |
67 | While building the project from source can be useful for trying out bleeding-edge features, **it is not recommended for production use.**
68 |
69 | :::
70 |
71 | First, clone down the repository and checkout the branch you'd like to build:
72 |
73 | ```bash
74 | git clone -b BRANCH_NAME https://github.com/fetcharr/fetcharr
75 | ```
76 |
77 | Afterwards, build the Docker image using [NUKE](https://nuke.build):
78 |
79 | ```bash
80 | ./build.cmd BuildImage # or `nuke BuildImage`, if you have it installed
81 | ```
82 |
83 | :::info
84 |
85 | You don't need to have NUKE installed for `build.cmd` to work.
86 |
87 | :::
88 |
--------------------------------------------------------------------------------
/src/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | true
5 | true
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/Configuration/src/Parsing/ServiceInstanceNodeDeserializer.cs:
--------------------------------------------------------------------------------
1 | using Fetcharr.Models.Configuration;
2 | using Fetcharr.Models.Configuration.Radarr;
3 | using Fetcharr.Models.Configuration.Sonarr;
4 |
5 | using YamlDotNet.Core;
6 | using YamlDotNet.Core.Events;
7 | using YamlDotNet.Serialization;
8 |
9 | namespace Fetcharr.Configuration.Parsing
10 | {
11 | public class ServiceInstanceNodeDeserializer : INodeDeserializer
12 | {
13 | public bool Deserialize(
14 | IParser reader,
15 | Type expectedType,
16 | Func nestedObjectDeserializer,
17 | out object? value,
18 | ObjectDeserializer objectDeserializer)
19 | {
20 | value = null;
21 |
22 | return expectedType switch
23 | {
24 | Type t when t == typeof(Dictionary)
25 | => this.Deserialize(reader, expectedType, nestedObjectDeserializer, out value, objectDeserializer),
26 |
27 | Type t when t == typeof(Dictionary)
28 | => this.Deserialize(reader, expectedType, nestedObjectDeserializer, out value, objectDeserializer),
29 |
30 | _ => false
31 | };
32 | }
33 |
34 | public bool Deserialize(
35 | IParser reader,
36 | Type expectedType,
37 | Func nestedObjectDeserializer,
38 | out object? value,
39 | ObjectDeserializer objectDeserializer)
40 | where T : FetcharrServiceConfiguration
41 | {
42 | if(expectedType != typeof(Dictionary))
43 | {
44 | value = null;
45 | return false;
46 | }
47 |
48 | if(!reader.TryConsume(out _))
49 | {
50 | value = null;
51 | return false;
52 | }
53 |
54 | Dictionary result = [];
55 | while(!reader.TryConsume(out _))
56 | {
57 | Scalar keyScalar = reader.Consume();
58 | T? input = nestedObjectDeserializer(reader, typeof(T)) as T;
59 |
60 | if(input is not null)
61 | {
62 | input.Name = keyScalar.Value;
63 | result.Add(input.Name, input);
64 | }
65 | }
66 |
67 | value = result;
68 | return true;
69 | }
70 | }
71 | }
--------------------------------------------------------------------------------
/.build/Build.Release.cs:
--------------------------------------------------------------------------------
1 | using Nuke.Common;
2 | using Nuke.Common.IO;
3 | using Nuke.Common.Tools.GitHub;
4 |
5 | using Octokit;
6 |
7 | partial class Build : NukeBuild
8 | {
9 | Target Release => _ => _
10 | .Description("Creates and pushes a new release to GitHub.\n")
11 | .DependsOn(BuildImage)
12 | .DependsOn(Publish)
13 | .Requires(() => this.GithubToken)
14 | .Executes(async () =>
15 | {
16 | ProductHeaderValue productInformation = new("fetcharr");
17 | GitHubTasks.GitHubClient = new GitHubClient(productInformation)
18 | {
19 | Credentials = new Credentials(this.GithubToken)
20 | };
21 |
22 | NewRelease newRelease = new(this.VersionTag)
23 | {
24 | Name = this.VersionTag,
25 | Prerelease = !this.IsReleaseBuild,
26 | Draft = false,
27 | GenerateReleaseNotes = true,
28 | MakeLatest = MakeLatestQualifier.True,
29 | };
30 |
31 | Release release;
32 |
33 | try
34 | {
35 | release = await GitHubTasks.GitHubClient.Repository.Release.Get(
36 | this.Repository.GetGitHubOwner(),
37 | this.Repository.GetGitHubName(),
38 | this.VersionTag);
39 |
40 | Serilog.Log.Information("Found existing release for version {Version}", this.VersionTag);
41 | }
42 | catch
43 | {
44 | release = await GitHubTasks.GitHubClient.Repository.Release.Create(
45 | this.Repository.GetGitHubOwner(),
46 | this.Repository.GetGitHubName(),
47 | newRelease);
48 |
49 | Serilog.Log.Information("Create new release for version {Version}", this.VersionTag);
50 | }
51 |
52 | foreach(AbsolutePath asset in AssetsDirectory.GlobFiles($"fetcharr-{VersionTag}-*.zip"))
53 | {
54 | Serilog.Log.Information("Uploading asset '{Asset}' to release...", asset.Name);
55 |
56 | using FileStream assetStream = File.OpenRead(asset);
57 |
58 | ReleaseAssetUpload assetUpload = new()
59 | {
60 | FileName = asset.Name,
61 | ContentType = "application/zip",
62 | RawData = assetStream
63 | };
64 |
65 | await GitHubTasks.GitHubClient.Repository.Release.UploadAsset(
66 | release,
67 | assetUpload
68 | );
69 | }
70 | });
71 | }
--------------------------------------------------------------------------------
/src/Testing/Containers/src/Radarr/RadarrConfiguration.cs:
--------------------------------------------------------------------------------
1 | namespace Fetcharr.Testing.Containers.Radarr
2 | {
3 | ///
4 | public sealed class RadarrConfiguration : ContainerConfiguration
5 | {
6 | ///
7 | /// Gets the Radarr API key.
8 | ///
9 | public string ApiKey { get; } = null!;
10 |
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | /// The API key.
15 | public RadarrConfiguration(string apiKey = null!)
16 | {
17 | this.ApiKey = apiKey;
18 | }
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | /// The Docker resource configuration.
24 | public RadarrConfiguration(IResourceConfiguration resourceConfiguration)
25 | : base(resourceConfiguration)
26 | {
27 |
28 | }
29 |
30 | ///
31 | /// Initializes a new instance of the class.
32 | ///
33 | /// The Docker resource configuration.
34 | public RadarrConfiguration(IContainerConfiguration resourceConfiguration)
35 | : base(resourceConfiguration)
36 | {
37 |
38 | }
39 |
40 | ///
41 | /// Initializes a new instance of the class.
42 | ///
43 | /// The Docker resource configuration.
44 | public RadarrConfiguration(RadarrConfiguration resourceConfiguration)
45 | : this(new RadarrConfiguration(), resourceConfiguration)
46 | {
47 | // Passes the configuration upwards to the base implementations to create an updated immutable copy.
48 | }
49 |
50 | ///
51 | /// Initializes a new instance of the class.
52 | ///
53 | /// The old Docker resource configuration.
54 | /// The new Docker resource configuration.
55 | public RadarrConfiguration(RadarrConfiguration oldValue, RadarrConfiguration newValue)
56 | : base(oldValue, newValue)
57 | {
58 | this.ApiKey = BuildConfiguration.Combine(oldValue.ApiKey, newValue.ApiKey);
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------