├── 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 | } --------------------------------------------------------------------------------