Repository: TypNull/Tubifarry
Branch: master
Commit: f6ed443d1819
Files: 282
Total size: 1.6 MB
Directory structure:
gitextract_31q9a7rf/
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── documentation_improvement.yml
│ │ └── feature_request.yml
│ └── workflows/
│ ├── branch-detection.yml
│ ├── compilation.yml
│ ├── dotnet-setup.yml
│ ├── git-info.yml
│ ├── main.yml
│ ├── metadata.yml
│ ├── packaging.yml
│ ├── publishing.yml
│ └── release-notes.yml
├── .gitignore
├── .gitmodules
├── CONTRIBUTION.md
├── Directory.Build.props
├── LICENSE.txt
├── NuGet.config
├── README.md
├── Tubifarry/
│ ├── Blocklisting/
│ │ ├── BaseBlocklist.cs
│ │ └── Blocklists.cs
│ ├── Core/
│ │ ├── Model/
│ │ │ ├── AlbumData.cs
│ │ │ ├── ApiCircuitBreaker.cs
│ │ │ ├── AudioMetadataHandler.cs
│ │ │ ├── FileCache.cs
│ │ │ ├── PlaylistItem.cs
│ │ │ └── TrustedSessionException.cs
│ │ ├── Records/
│ │ │ ├── Lyric.cs
│ │ │ ├── MappingAgent.cs
│ │ │ ├── MusicBrainzData.cs
│ │ │ └── YouTubeSession.cs
│ │ ├── Replacements/
│ │ │ ├── ExtendedHttpIndexerBase.cs
│ │ │ ├── FlexibleHttpDispatcher.cs
│ │ │ └── IIndexerRequestGenerator.cs
│ │ ├── Telemetry/
│ │ │ ├── ISearchContextBuffer.cs
│ │ │ ├── ISentryHelper.cs
│ │ │ ├── NoopSentryHelper.cs
│ │ │ ├── SearchContextBuffer.cs
│ │ │ ├── SentryEventFilter.cs
│ │ │ ├── SentryHelper.cs
│ │ │ ├── SlskdBufferedContext.cs
│ │ │ ├── SlskdSentryEvents.cs
│ │ │ ├── SlskdTrackingService.cs
│ │ │ ├── TubifarrySentry.cs
│ │ │ └── TubifarrySentryTarget.cs
│ │ └── Utilities/
│ │ ├── AudioFormat.cs
│ │ ├── CacheService.cs
│ │ ├── CookieManager.cs
│ │ ├── DynamicSchemaInjector.cs
│ │ ├── DynamicStateSettings.cs
│ │ ├── FileInfoParser.cs
│ │ ├── IndexerParserHelper.cs
│ │ ├── JsonConverters.cs
│ │ ├── LazyRequestChain.cs
│ │ ├── PermissionTester.cs
│ │ ├── PluginSettings.cs
│ │ ├── ReleaseFormatter.cs
│ │ ├── RepositorySettingsResolver.cs
│ │ └── UserAgentValidator.cs
│ ├── Debug.targets
│ ├── Download/
│ │ ├── Base/
│ │ │ ├── BaseDownloadManager.cs
│ │ │ ├── BaseDownloadOptions.cs
│ │ │ ├── BaseDownloadRequest.cs
│ │ │ └── BaseHttpClient.cs
│ │ └── Clients/
│ │ ├── DABMusic/
│ │ │ ├── DABMusicClient.cs
│ │ │ ├── DABMusicDownloadManager.cs
│ │ │ ├── DABMusicDownloadOptions.cs
│ │ │ ├── DABMusicDownloadRequest.cs
│ │ │ └── DABMusicProviderSettings.cs
│ │ ├── Lucida/
│ │ │ ├── ILucidaRateLimiter.cs
│ │ │ ├── LucidaClient.cs
│ │ │ ├── LucidaDownloadManager.cs
│ │ │ ├── LucidaDownloadOptions.cs
│ │ │ ├── LucidaDownloadRequest.cs
│ │ │ ├── LucidaInitiationResult.cs
│ │ │ ├── LucidaMetadataExtractor.cs
│ │ │ ├── LucidaProviderSettings.cs
│ │ │ ├── LucidaRateLimitException.cs
│ │ │ ├── LucidaRateLimiter.cs
│ │ │ ├── LucidaTokenExtractor.cs
│ │ │ └── LucidaWorkerState.cs
│ │ ├── Soulseek/
│ │ │ ├── ISlskdApiClient.cs
│ │ │ ├── ISlskdDownloadManager.cs
│ │ │ ├── Models/
│ │ │ │ ├── DownloadKey.cs
│ │ │ │ ├── SlskdDownloadDirectory.cs
│ │ │ │ ├── SlskdDownloadFile.cs
│ │ │ │ ├── SlskdDownloadItem.cs
│ │ │ │ └── SlskdFileState.cs
│ │ │ ├── SlskdApiClient.cs
│ │ │ ├── SlskdClient.cs
│ │ │ ├── SlskdDownloadManager.cs
│ │ │ ├── SlskdProviderSettings.cs
│ │ │ ├── SlskdRetryHandler.cs
│ │ │ └── SlskdStatusResolver.cs
│ │ ├── SubSonic/
│ │ │ ├── SubSonicClient.cs
│ │ │ ├── SubSonicDownloadManager.cs
│ │ │ ├── SubSonicDownloadOptions.cs
│ │ │ ├── SubSonicDownloadRequest.cs
│ │ │ └── SubSonicProviderSettings.cs
│ │ ├── TripleTriple/
│ │ │ ├── TripleTripleClient.cs
│ │ │ ├── TripleTripleDownloadManager.cs
│ │ │ ├── TripleTripleDownloadOptions.cs
│ │ │ ├── TripleTripleDownloadRequest.cs
│ │ │ └── TripleTripleProviderSettings.cs
│ │ └── YouTube/
│ │ ├── SponsorBlock.cs
│ │ ├── TrustedSessionHelper.cs
│ │ ├── YouTubeDownloadOptions.cs
│ │ ├── YouTubeDownloadRequest.cs
│ │ ├── YoutubeClient.cs
│ │ ├── YoutubeDownloadManager.cs
│ │ └── YoutubeProviderSettings.cs
│ ├── ILRepack.targets
│ ├── ImportLists/
│ │ ├── ArrStack/
│ │ │ ├── ArrMedia.cs
│ │ │ ├── ArrSoundtrackImport.cs
│ │ │ ├── ArrSoundtrackImportParser.cs
│ │ │ ├── ArrSoundtrackImportSettings.cs
│ │ │ └── ArrSoundtrackRequestGenerator.cs
│ │ ├── LastFmRecommendation/
│ │ │ ├── LastFmRecomendRequestGenerator.cs
│ │ │ ├── LastFmRecommend.cs
│ │ │ ├── LastFmRecommendParser.cs
│ │ │ ├── LastFmRecommendSettings.cs
│ │ │ └── LastFmRecords.cs
│ │ ├── ListenBrainz/
│ │ │ ├── ListenBrainzCFRecommendations/
│ │ │ │ ├── ListenBrainzCFRecommendationsImportList.cs
│ │ │ │ ├── ListenBrainzCFRecommendationsParser.cs
│ │ │ │ ├── ListenBrainzCFRecommendationsRequestGenerator.cs
│ │ │ │ └── ListenBrainzCFRecommendationsSettings.cs
│ │ │ ├── ListenBrainzCreatedForPlaylist/
│ │ │ │ ├── ListenBrainzCreatedForPlaylistImportList.cs
│ │ │ │ ├── ListenBrainzCreatedForPlaylistParser.cs
│ │ │ │ ├── ListenBrainzCreatedForPlaylistRequestGenerator.cs
│ │ │ │ └── ListenBrainzCreatedForPlaylistSettings.cs
│ │ │ ├── ListenBrainzPlaylist/
│ │ │ │ ├── ListenBrainzPlaylistImportList.cs
│ │ │ │ ├── ListenBrainzPlaylistParser.cs
│ │ │ │ ├── ListenBrainzPlaylistRequestGenerator.cs
│ │ │ │ └── ListenBrainzPlaylistSettings.cs
│ │ │ ├── ListenBrainzRecords.cs
│ │ │ └── ListenBrainzUserStats/
│ │ │ ├── ListenBrainzUserStatsImportList.cs
│ │ │ ├── ListenBrainzUserStatsParser.cs
│ │ │ ├── ListenBrainzUserStatsRequestGenerator.cs
│ │ │ └── ListenBrainzUserStatsSettings.cs
│ │ └── Spotify/
│ │ ├── SpotifyUserPlaylistImport.cs
│ │ └── SpotifyUserPlaylistImportSettings.cs
│ ├── Indexers/
│ │ ├── DABMusic/
│ │ │ ├── DABMusicIndexer.cs
│ │ │ ├── DABMusicIndexerSettings.cs
│ │ │ ├── DABMusicParser.cs
│ │ │ ├── DABMusicRecords.cs
│ │ │ ├── DABMusicRequestGenerator.cs
│ │ │ └── DABMusicSessionHelper.cs
│ │ ├── DownloadProtocols.cs
│ │ ├── Lucida/
│ │ │ ├── LucidaIndexer.cs
│ │ │ ├── LucidaIndexerSettings.cs
│ │ │ ├── LucidaRecords.cs
│ │ │ ├── LucidaRequestGenerator.cs
│ │ │ ├── LucidaRequestParser.cs
│ │ │ └── LucidaServiceHelper.cs
│ │ ├── Soulseek/
│ │ │ ├── ISlskdItemsParser.cs
│ │ │ ├── Search/
│ │ │ │ ├── Core/
│ │ │ │ │ ├── ISearchStrategy.cs
│ │ │ │ │ ├── QueryAnalyzer.cs
│ │ │ │ │ ├── SearchContext.cs
│ │ │ │ │ └── SearchPipeline.cs
│ │ │ │ ├── Strategies/
│ │ │ │ │ ├── BaseSearchStrategy.cs
│ │ │ │ │ ├── FallbackStrategy.cs
│ │ │ │ │ ├── SpecialCaseStrategy.cs
│ │ │ │ │ ├── TemplateSearchStrategy.cs
│ │ │ │ │ └── VariationStrategy.cs
│ │ │ │ ├── Templates/
│ │ │ │ │ └── TemplateEngine.cs
│ │ │ │ └── Transformers/
│ │ │ │ ├── QueryBuilder.cs
│ │ │ │ └── QueryNormalizer.cs
│ │ │ ├── SlsdkRecords.cs
│ │ │ ├── SlskdIndexer.cs
│ │ │ ├── SlskdIndexerParser.cs
│ │ │ ├── SlskdItemsParser.cs
│ │ │ ├── SlskdRequestGenerator.cs
│ │ │ ├── SlskdSettings.cs
│ │ │ └── SlskdTextProcessor.cs
│ │ ├── Spotify/
│ │ │ ├── SpotifyIndexerSettings.cs
│ │ │ ├── SpotifyParser.cs
│ │ │ ├── SpotifyRequestGenerator.cs
│ │ │ ├── SpotifyToYouTubeEnricher.cs
│ │ │ └── TubifarryIndexer.cs
│ │ ├── SubSonic/
│ │ │ ├── SubSonicAuthHelper.cs
│ │ │ ├── SubSonicIndexer.cs
│ │ │ ├── SubSonicIndexerParser.cs
│ │ │ ├── SubSonicIndexerSettings.cs
│ │ │ ├── SubSonicRecords.cs
│ │ │ └── SubSonicRequestGenerator.cs
│ │ ├── TripleTriple/
│ │ │ ├── TripleTripleIndexer.cs
│ │ │ ├── TripleTripleIndexerSettings.cs
│ │ │ ├── TripleTripleParser.cs
│ │ │ ├── TripleTripleRecords.cs
│ │ │ └── TripleTripleRequestGenerator.cs
│ │ └── YouTube/
│ │ ├── YoutubeIndexer.cs
│ │ ├── YoutubeIndexerSettings.cs
│ │ ├── YoutubeParser.cs
│ │ └── YoutubeRequestGenerator.cs
│ ├── Metadata/
│ │ ├── Converter/
│ │ │ ├── AudioConverter.cs
│ │ │ ├── AudioConverterSettings.cs
│ │ │ └── BitrateRules.cs
│ │ ├── Lyrics/
│ │ │ ├── LyricEnhancerSettings.cs
│ │ │ ├── LyricsEnhancer.cs
│ │ │ ├── LyricsHelper.cs
│ │ │ ├── LyricsProviders.cs
│ │ │ └── TrackFileRepositoryHelper.cs
│ │ ├── Proxy/
│ │ │ ├── MetadataProvider/
│ │ │ │ ├── AlbumMapper.cs
│ │ │ │ ├── CustomLidarr/
│ │ │ │ │ ├── CustomLidarrMetadataProxy.cs
│ │ │ │ │ ├── CustomLidarrMetadataProxySettings.cs
│ │ │ │ │ ├── CustomLidarrProxy.cs
│ │ │ │ │ └── ICustomLidarrProxy.cs
│ │ │ │ ├── Deezer/
│ │ │ │ │ ├── DeezerAPIService.cs
│ │ │ │ │ ├── DeezerAuthService.cs
│ │ │ │ │ ├── DeezerMappingHelper.cs
│ │ │ │ │ ├── DeezerMetadataProxy.cs
│ │ │ │ │ ├── DeezerMetadataProxySettings.cs
│ │ │ │ │ ├── DeezerProxy.cs
│ │ │ │ │ ├── DeezerRecords.cs
│ │ │ │ │ └── IDeezerProxy.cs
│ │ │ │ ├── Discogs/
│ │ │ │ │ ├── DiscogsAPIService.cs
│ │ │ │ │ ├── DiscogsMappingHelper.cs
│ │ │ │ │ ├── DiscogsMetadataProxy.cs
│ │ │ │ │ ├── DiscogsMetadataProxySettings.cs
│ │ │ │ │ ├── DiscogsProxy.cs
│ │ │ │ │ ├── DiscogsRecords.cs
│ │ │ │ │ └── IDiscogsProxy.cs
│ │ │ │ ├── Lastfm/
│ │ │ │ │ ├── ILastfmProxy.cs
│ │ │ │ │ ├── LastfmApiService.cs
│ │ │ │ │ ├── LastfmImageScraper.cs
│ │ │ │ │ ├── LastfmMappingHelper.cs
│ │ │ │ │ ├── LastfmMetadataProxy.cs
│ │ │ │ │ ├── LastfmMetadataProxySettings.cs
│ │ │ │ │ ├── LastfmProxy.cs
│ │ │ │ │ ├── LastfmRecordConverter.cs
│ │ │ │ │ └── LastfmRecords.cs
│ │ │ │ ├── MetadataProviderWrapper.cs
│ │ │ │ ├── Mixed/
│ │ │ │ │ ├── AdaptiveThresholdConfig.cs
│ │ │ │ │ ├── AdaptiveThresholdManager.cs
│ │ │ │ │ ├── ISupportMetadataMixing.cs
│ │ │ │ │ ├── MixedMetadataProxy.cs
│ │ │ │ │ ├── MixedMetadataProxySettings.cs
│ │ │ │ │ ├── ProxyDecisionHandler.cs
│ │ │ │ │ └── ProxyMetrics.cs
│ │ │ │ └── SkyHook/
│ │ │ │ ├── SkyHookMetdadataProxy.cs
│ │ │ │ └── SykHookMetadataProxySettings.cs
│ │ │ ├── MixedProxyBase.cs
│ │ │ ├── ProxyAttribute.cs
│ │ │ ├── ProxyBase.cs
│ │ │ ├── ProxyService.cs
│ │ │ ├── ProxyServiceStarter.cs
│ │ │ ├── ProxyWrapperBase.cs
│ │ │ └── RecommendArtists/
│ │ │ ├── LastFmSimilarArtistsService.cs
│ │ │ ├── SimilarArtistsProxy.cs
│ │ │ └── SimilarArtistsProxySettings.cs
│ │ └── ScheduledTasks/
│ │ ├── IProvideScheduledTask.cs
│ │ ├── ScheduledTaskBase.cs
│ │ ├── ScheduledTaskService.cs
│ │ ├── ScheduledTaskServiceStarter.cs
│ │ └── SearchSniper/
│ │ ├── SearchSniperRepositoryHelper.cs
│ │ ├── SearchSniperTask.cs
│ │ └── SearchSniperTaskSettings.cs
│ ├── Notifications/
│ │ ├── FlareSolverr/
│ │ │ ├── FlareDetector.cs
│ │ │ ├── FlareRecords.cs
│ │ │ ├── FlareSolverrHttpInterceptor.cs
│ │ │ ├── FlareSolverrNotification.cs
│ │ │ ├── FlareSolverrService.cs
│ │ │ ├── FlareSolverrSettings.cs
│ │ │ └── IFlareSolverrService.cs
│ │ ├── PlaylistExport/
│ │ │ ├── PlaylistExportNotification.cs
│ │ │ ├── PlaylistExportService.cs
│ │ │ └── PlaylistExportSettings.cs
│ │ ├── QueueCleaner/
│ │ │ ├── ImportFailureNotificationService.cs
│ │ │ ├── QueueCleaner.cs
│ │ │ └── QueueCleanerSettings.cs
│ │ └── YouTubeProxy/
│ │ ├── YouTubeProxyNotification.cs
│ │ ├── YouTubeProxyService.cs
│ │ └── YouTubeProxySettings.cs
│ ├── Plugin.cs
│ ├── PluginInfo.targets
│ ├── PluginKeys.targets
│ ├── PreBuild.targets
│ └── Tubifarry.csproj
├── Tubifarry.sln
└── stylecop.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.yml
================================================
name: "Bug Report"
description: Report a bug or unexpected behavior in Tubifarry
title: "[BUG] "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
# Thank you for taking the time to report an issue!
Your feedback helps us make Tubifarry better for everyone.
- type: input
id: tubifarry-version
attributes:
label: Tubifarry Version
description: |
What version of Tubifarry are you running?
Find this in: System → Plugin.
Format must be: v1.8.1 (not "latest" or "newest")
placeholder: v1.8.1
validations:
required: true
- type: input
id: lidarr-version
attributes:
label: Lidarr Version
description: |
What version of Lidarr are you using?
Find this in: System → Status
Format must be: v2.14.2 (not "latest")
placeholder: v2.14.2
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Windows
- macOS
- Linux
- Docker
- Other (specify in additional context)
validations:
required: true
- type: textarea
id: bug-description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is and the specific feature that's not working.
placeholder: |
Example: "I was trying to... and then... The search function doesn't work as expected because..."
Describe what you were trying to do and what went wrong:
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Provide exact steps so we can recreate the issue
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: I expected to see...
validations:
required: true
- type: markdown
attributes:
value: |
## How to Enable Trace Logging
1. Go to Lidarr → Settings → General → Logging
2. Set Log Level to **Trace** (not Debug, not Info)
3. Click Save
4. Reproduce your exact problem
5. Go to System → Logs → Files tab
6. Download the most recent log file
7. Paste the relevant section below
If your logs do not contain the word **`trace`**, your issue will be closed
- type: textarea
id: logs
attributes:
label: Trace Log Files
description: |
Paste only the relevant trace logs here. Do not delete the details tags.
value: |
Logs
```plaintext
Keep the plaintext markers intact and paste your logs in here...
```
validations:
required: true
- type: checkboxes
id: log-checklist
attributes:
label: Log Checklist
options:
- label: I have enabled Trace logging in Lidarr before reproducing the issue
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
value: |
Screenshots
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that might help us resolve the issue
validations:
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
# .github/ISSUE_TEMPLATE/config.yml
blank_issues_enabled: false
contact_links:
- name: "Questions & Support"
url: https://github.com/TypNull/Tubifarry/discussions
about: Ask questions or get help with Tubifarry in our Discussions forum.
- name: "Documentation"
url: https://github.com/TypNull/Tubifarry/wiki
about: Check out the official documentation for Tubifarry.
================================================
FILE: .github/ISSUE_TEMPLATE/documentation_improvement.yml
================================================
name: "Documentation Improvement"
description: Report issues or suggest improvements for Tubifarry documentation
title: "[DOCS] "
labels: ["documentation"]
assignees: []
body:
- type: markdown
attributes:
value: |
## Documentation Improvement Request
Thank you for helping us improve our documentation!
- type: dropdown
id: doc-type
attributes:
label: Documentation Type
description: What type of documentation needs improvement?
options:
- Installation Guide
- User Manual
- API Reference
- FAQ
- Tutorial
- Wiki Page
- README
- Other (specify in description)
validations:
required: true
- type: input
id: doc-location
attributes:
label: Documentation Location
description: URL or path to the documentation that needs improvement
placeholder: https://github.com/TypNull/Tubifarry/wiki/Installation or /docs/installation.md
validations:
required: true
- type: textarea
id: current-state
attributes:
label: Current State
description: Describe what is currently in the documentation and why it needs improvement
placeholder: The current documentation is missing information about...
validations:
required: true
- type: textarea
id: suggested-changes
attributes:
label: Suggested Changes
description: Describe your suggested changes or additions
placeholder: |
I suggest adding a section about...
The following information should be included...
validations:
required: true
- type: textarea
id: proposed-text
attributes:
label: Proposed Text
description: If you have specific wording in mind, please provide it here
value: |
Click to expand proposed text
## New Section Title
Here is my suggested text for this section...
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots or Examples
description: If applicable, add screenshots or examples to illustrate your point
value: |
Click to expand screenshots
Upload or paste your screenshots here...
validations:
required: false
- type: checkboxes
id: contribution
attributes:
label: Contribution
options:
- label: I am willing to submit a pull request with these changes
required: false
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.yml
================================================
name: "Feature Request"
description: Suggest an idea or improvement for Tubifarry
title: "[FEATURE] "
labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
## Thanks for taking the time to suggest a feature!
Please provide detailed information about your feature idea.
Check existing issues first to avoid duplicates.
- type: textarea
id: feature-request
attributes:
label: Feature Request
description: Describe what you'd like to see added and why it would be valuable.
placeholder: |
I would like Tubifarry to support [specific feature] because [reason].
This would improve the experience by...
The feature should work like [detailed description].
validations:
required: true
- type: dropdown
id: importance
attributes:
label: Priority Level
description: How important is this feature for your workflow?
options:
- Nice to have (low priority)
- Would improve my experience (medium priority)
- Critical to my use case (high priority)
validations:
required: true
- type: textarea
id: mockups
attributes:
label: Visual Designs or Mockups
description: If you have any mockups, wireframes, or designs, please share them here.
value: |
Mockups
validations:
required: false
- type: dropdown
id: contribution
attributes:
label: Are you willing to contribute to this feature?
description: Let us know if you'd be interested in implementing this feature
options:
- No, I'm just suggesting the idea
- I might contribute, but I'd need guidance
- Yes, I'd be willing to implement this feature
validations:
required: true
- type: textarea
id: technical
attributes:
label: Technical Details (Optional)
description: If you have technical expertise or ideas for implementation, feel free to share.
value: |
Technical Details
validations:
required: false
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues to ensure this is not a duplicate
required: true
================================================
FILE: .github/workflows/branch-detection.yml
================================================
name: Detect Branch from Tag or Reference
on:
workflow_call:
inputs:
github_ref:
required: true
type: string
description: "The GitHub reference (usually github.ref)"
outputs:
branch_name:
description: "The detected branch name"
value: ${{ jobs.detect.outputs.branch_name }}
jobs:
detect:
runs-on: ubuntu-latest
outputs:
branch_name: ${{ steps.detect_branch.outputs.branch_name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure all Git refs are available
run: |
git fetch --prune --unshallow || git fetch --prune
git fetch --tags --force
echo "Available remote branches:"
git branch -r
echo "Available tags:"
git tag -l
- name: Detect Branch
id: detect_branch
run: |
# Enhanced branch detection from tag names
if [[ "${{ inputs.github_ref }}" == refs/tags/* ]]; then
TAG_NAME=${{ inputs.github_ref }}
TAG_NAME=${TAG_NAME#refs/tags/}
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
# Check if tag contains branch info in format v1.2.3-branch-name
if [[ "$TAG_NAME" == v*-* ]]; then
# Extract potential branch name after the dash
TAG_BRANCH=${TAG_NAME#*-}
echo "Detected potential branch suffix in tag: $TAG_BRANCH"
# List all available branches and look for best match
AVAILABLE_BRANCHES=$(git branch -r | grep -v HEAD | sed -e 's/ *origin\///g')
echo "Available branches:"
echo "$AVAILABLE_BRANCHES"
# First, try exact match
if echo "$AVAILABLE_BRANCHES" | grep -q "^$TAG_BRANCH$"; then
BRANCH_NAME="$TAG_BRANCH"
echo "Found exact branch match: $BRANCH_NAME"
# Then try to match longer branch names that contain the tag suffix
elif echo "$AVAILABLE_BRANCHES" | grep -q "$TAG_BRANCH"; then
BRANCH_NAME=$(echo "$AVAILABLE_BRANCHES" | grep "$TAG_BRANCH" | head -n 1)
echo "Found branch containing tag suffix: $BRANCH_NAME"
fi
fi
# If no branch name from tag, try to find branch that contains this tag
if [ -z "$BRANCH_NAME" ]; then
echo "No branch found from tag suffix, trying git branch --contains..."
CONTAINING_BRANCHES=$(git branch -r --contains $TAG_NAME | grep -v HEAD | sed -e 's/ *origin\///g')
echo "Branches containing this tag: $CONTAINING_BRANCHES"
# Prioritize master/main when multiple branches contain the commit
if echo "$CONTAINING_BRANCHES" | grep -qE "^(master|main)$"; then
BRANCH_NAME=$(echo "$CONTAINING_BRANCHES" | grep -E "^(master|main)$" | head -n 1)
echo "Prioritizing default branch: $BRANCH_NAME"
elif echo "$CONTAINING_BRANCHES" | grep -q "^$DEFAULT_BRANCH$"; then
BRANCH_NAME="$DEFAULT_BRANCH"
echo "Using repository default branch: $BRANCH_NAME"
else
BRANCH_NAME=$(echo "$CONTAINING_BRANCHES" | head -n 1)
echo "Using first available branch: $BRANCH_NAME"
fi
fi
# Fallback to the default branch if no branch is found
if [ -z "$BRANCH_NAME" ]; then
BRANCH_NAME=$DEFAULT_BRANCH
echo "Tag not associated with branch, using default branch: $BRANCH_NAME"
fi
else
# When triggered by a push, use GITHUB_REF directly
BRANCH_NAME=${{ inputs.github_ref }}
BRANCH_NAME=${BRANCH_NAME#refs/heads/}
fi
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "Detected branch: $BRANCH_NAME"
================================================
FILE: .github/workflows/compilation.yml
================================================
name: Compile Project
on:
workflow_call:
inputs:
dotnet_version:
required: true
type: string
description: ".NET SDK version to use"
plugin_name:
required: true
type: string
description: "Name of the plugin"
package_version:
required: true
type: string
description: "Version number for the package"
framework:
required: true
type: string
description: "Framework version extracted from project file"
git_commit:
required: true
type: string
description: "Git commit SHA used for the build"
git_branch:
required: true
type: string
description: "Git branch name"
git_tag:
required: false
type: string
description: "Git tag if available"
default: ""
repo_url:
required: true
type: string
description: "Base repository URL"
branch_repo_url:
required: true
type: string
description: "Repository URL with branch path"
repo_owner:
required: true
type: string
description: "Repository owner"
minimum_lidarr_version:
required: false
type: string
description: "Minimum Lidarr version required"
default: ""
secrets:
SPOTIFY_CLIENT_ID:
required: false
description: "Spotify API Client ID"
SPOTIFY_CLIENT_SECRET:
required: false
description: "Spotify API Client Secret"
YOUTUBE_SECRET:
required: false
description: "YouTube API Secret"
SENTRY_DSN:
required: false
description: "Sentry DSN for error reporting"
outputs:
build_status:
description: "Status of the build"
value: ${{ jobs.compile.outputs.build_status }}
plugin_output_path:
description: "Path to the built plugin output directory"
value: ${{ jobs.compile.outputs.plugin_output_path }}
jobs:
compile:
runs-on: ubuntu-latest
outputs:
build_status: ${{ steps.build_result.outputs.status }}
plugin_output_path: ${{ steps.find_output.outputs.plugin_path }}
steps:
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: 'recursive'
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ inputs.dotnet_version }}
# Add NuGet package caching
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Create global.json
run: |
echo '{"sdk":{"version": "${{ inputs.dotnet_version }}"}}' > ./global.json
- name: Create Release Notes
id: release_notes
run: |
mkdir -p ./Properties
cat > ./Properties/release_notes.txt << EOL
Version: ${{ inputs.package_version }}
Branch: ${{ inputs.git_branch }}
Commit: ${{ inputs.git_commit }}
Framework: ${{ inputs.framework }}
Repository: ${{ inputs.branch_repo_url }}
Minimum Lidarr Version: ${{ inputs.minimum_lidarr_version }}
Built at: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
Plugin Info:
Name: ${{ inputs.plugin_name }}
Owner: ${{ inputs.repo_owner }}
EOL
echo "release_notes=./Properties/release_notes.txt" >> $GITHUB_OUTPUT
- name: Display Build Parameters
run: |
echo "Plugin Name: ${{ inputs.plugin_name }}"
echo "Package Version: ${{ inputs.package_version }}"
echo "Git Branch: ${{ inputs.git_branch }}"
echo "Git Commit: ${{ inputs.git_commit }}"
echo "Git Tag: ${{ inputs.git_tag }}"
echo "Repo URL: ${{ inputs.repo_url }}"
echo "Branch Repo URL: ${{ inputs.branch_repo_url }}"
echo "Repo Owner: ${{ inputs.repo_owner }}"
echo "Release Notes Path: ${{ steps.release_notes.outputs.release_notes }}"
echo "Minimum Lidarr Version: ${{ inputs.minimum_lidarr_version }}"
echo "Spotify Client ID is configured: ${{ secrets.SPOTIFY_CLIENT_ID != '' }}"
echo "Spotify Client Secret is configured: ${{ secrets.SPOTIFY_CLIENT_SECRET != '' }}"
echo "YouTube Secret is configured: ${{ secrets.YOUTUBE_SECRET != '' }}"
echo "Sentry DSN is configured: ${{ secrets.SENTRY_DSN != '' }}"
- name: Build with package version and metadata
id: build_step
env:
SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }}
SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }}
YOUTUBE_SECRET: ${{ secrets.YOUTUBE_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
# Restore NuGet packages first
dotnet restore *.sln
# Then build with all parameters
dotnet build *.sln -c Release -f ${{ inputs.framework }} \
-p:Version=${{ inputs.package_version }} \
-p:AssemblyVersion=${{ inputs.package_version }} \
-p:FileVersion=${{ inputs.package_version }} \
-p:Branch="${{ inputs.git_branch }}" \
-p:GitCommit="${{ inputs.git_commit }}" \
-p:GitTag="${{ inputs.git_tag }}" \
-p:RepoUrl="${{ inputs.branch_repo_url }}" \
-p:Author="${{ inputs.repo_owner }}" \
-p:ReleaseNotesFile="${{ steps.release_notes.outputs.release_notes }}" \
-p:MinimumLidarrVersion="${{ inputs.minimum_lidarr_version }}" \
-p:SpotifyClientId=$SPOTIFY_CLIENT_ID \
-p:SpotifyClientSecret=$SPOTIFY_CLIENT_SECRET \
-p:YouTubeSecret=$YOUTUBE_SECRET \
-p:SentryDsn=$SENTRY_DSN \
-p:CI="true" \
-v:n
- name: Find Plugin Output Directory
id: find_output
run: |
# More thorough search for the plugin output directory
echo "Searching for plugin output directories..."
find . -name "_plugins" -type d
# Find the plugin output directory
PLUGIN_OUTPUT_DIR=$(find . -type d -path "*/_plugins/*/${{ inputs.plugin_name }}" | head -n 1)
if [ -z "$PLUGIN_OUTPUT_DIR" ]; then
echo "Trying alternate search pattern..."
PLUGIN_OUTPUT_DIR=$(find . -type d -path "*/_plugins/*" -name "${{ inputs.plugin_name }}" | head -n 1)
fi
# If still not found, use the first plugin directory we can find
if [ -z "$PLUGIN_OUTPUT_DIR" ]; then
echo "Plugin directory not found by name. Using first available plugin directory..."
FIRST_PLUGINS_DIR=$(find . -type d -path "*/_plugins/*" | sort | head -n 1)
if [ -n "$FIRST_PLUGINS_DIR" ]; then
PLUGIN_OUTPUT_DIR=$FIRST_PLUGINS_DIR
echo "Using plugin directory: $PLUGIN_OUTPUT_DIR"
else
echo "::error::No plugin directories found at all! Build may have failed to produce plugin files."
exit 1
fi
fi
echo "Plugin output directory: $PLUGIN_OUTPUT_DIR"
# Check if directory contains any files at all
FILES_COUNT=$(find "$PLUGIN_OUTPUT_DIR" -type f | wc -l)
if [ "$FILES_COUNT" -eq 0 ]; then
echo "::error::Directory is empty! Build may have failed to produce plugin files."
exit 1
fi
echo "Found $FILES_COUNT files in $PLUGIN_OUTPUT_DIR"
echo "plugin_path=$PLUGIN_OUTPUT_DIR" >> $GITHUB_OUTPUT
# Copy release notes to output directory
cp ${{ steps.release_notes.outputs.release_notes }} $PLUGIN_OUTPUT_DIR/release_notes.txt
- name: Record Build Result
id: build_result
run: |
echo "status=success" >> $GITHUB_OUTPUT
# Upload the actual plugin files
- name: Upload Plugin Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-output
path: ${{ steps.find_output.outputs.plugin_path }}
retention-days: 1
- name: Upload Build Log
if: always()
uses: actions/upload-artifact@v4
with:
name: build-logs
path: |
./**/*.binlog
./**/*.log
retention-days: 1
if-no-files-found: warn
================================================
FILE: .github/workflows/dotnet-setup.yml
================================================
name: Setup .NET Environment
on:
workflow_call:
inputs:
dotnet_version:
required: true
type: string
description: ".NET SDK version to use"
framework:
required: false
type: string
description: "Framework version"
default: "net6.0"
outputs:
submodules_ready:
description: "Whether submodules were initialized successfully"
value: ${{ jobs.setup.outputs.submodules_ready }}
jobs:
setup:
runs-on: ubuntu-latest
outputs:
submodules_ready: ${{ steps.check_submodules.outputs.ready }}
steps:
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: 'recursive'
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ inputs.dotnet_version }}
# Add NuGet package caching
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Create global.json
run: |
echo '{"sdk":{"version": "${{ inputs.dotnet_version }}"}}' > ./global.json
- name: Initialize and verify submodules
id: check_submodules
run: |
echo "Listing Submodules directory:"
ls -la Submodules/ || echo "Submodules directory not found"
if [ -d "Submodules/Lidarr" ]; then
echo "Listing Lidarr submodule:"
ls -la Submodules/Lidarr/
echo "Listing Lidarr source directory:"
ls -la Submodules/Lidarr/src/ || echo "Lidarr src directory not found"
else
echo "Lidarr submodule not found"
fi
echo "Checking .gitmodules file:"
cat .gitmodules || echo ".gitmodules file not found"
# Initialize submodules if they weren't checked out properly
if [ ! -d "Submodules/Lidarr/src" ]; then
echo "Manually initializing submodules..."
git submodule update --init --recursive
fi
# Verify that submodules are now properly initialized
if [ -d "Submodules/Lidarr/src" ]; then
echo "ready=true" >> $GITHUB_OUTPUT
echo "Submodules successfully initialized"
else
echo "ready=false" >> $GITHUB_OUTPUT
echo "Warning: Submodules could not be initialized"
fi
================================================
FILE: .github/workflows/git-info.yml
================================================
name: Extract Git Information
on:
workflow_call:
inputs:
tag_ref:
required: true
type: string
description: "The tag reference (usually github.ref)"
branch_name:
required: true
type: string
description: "Branch name (passed from main workflow)"
outputs:
git_commit:
description: "Git commit SHA used for the build"
value: ${{ jobs.git_info.outputs.git_commit }}
git_branch:
description: "Git branch name"
value: ${{ jobs.git_info.outputs.git_branch }}
git_tag:
description: "Git tag if available"
value: ${{ jobs.git_info.outputs.git_tag }}
repo_url:
description: "Repository URL (base URL without branch)"
value: ${{ jobs.git_info.outputs.repo_url }}
branch_repo_url:
description: "Repository URL with branch path for non-master branches"
value: ${{ jobs.git_info.outputs.branch_repo_url }}
repo_owner:
description: "Repository owner"
value: ${{ jobs.git_info.outputs.repo_owner }}
commit_messages:
description: "Commit messages since last release"
value: ${{ jobs.git_info.outputs.commit_messages }}
is_latestrelease:
description: "Whether this should be the newest stable release"
value: ${{ jobs.git_info.outputs.is_latestrelease }}
plugin_input_format:
description: "Plugin input format: name@owner#branch"
value: ${{ jobs.git_info.outputs.plugin_input_format }}
jobs:
git_info:
runs-on: ubuntu-latest
outputs:
git_commit: ${{ steps.extract_git_info.outputs.git_commit }}
git_branch: ${{ steps.extract_git_info.outputs.git_branch }}
git_tag: ${{ steps.extract_git_info.outputs.git_tag }}
repo_url: ${{ steps.extract_git_info.outputs.repo_url }}
branch_repo_url: ${{ steps.extract_git_info.outputs.branch_repo_url }}
repo_owner: ${{ steps.extract_git_info.outputs.repo_owner }}
commit_messages: ${{ steps.get_commit_messages.outputs.commit_messages }}
is_latestrelease: ${{ steps.check_latest_release.outputs.is_latestrelease }}
plugin_input_format: ${{ steps.extract_git_info.outputs.plugin_input_format }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
run: |
git fetch --tags --force
- name: Extract Git Information
id: extract_git_info
run: |
# Extract commit hash
GIT_COMMIT=$(git rev-parse --short HEAD)
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
echo "Git commit: $GIT_COMMIT"
# Use branch name passed from main workflow
BRANCH_NAME="${{ inputs.branch_name }}"
echo "git_branch=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "Using branch name: $BRANCH_NAME"
# Extract tag name
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "git_tag=$GIT_TAG" >> $GITHUB_OUTPUT
echo "Git tag: $GIT_TAG"
# Extract repository URL
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "Repository URL: $REPO_URL"
# Create branch-specific repository URL (master/main uses base URL)
if [[ "$BRANCH_NAME" != "master" && "$BRANCH_NAME" != "main" ]]; then
BRANCH_REPO_URL="$REPO_URL/tree/$BRANCH_NAME"
echo "Branch-specific URL: $BRANCH_REPO_URL"
else
BRANCH_REPO_URL="$REPO_URL"
echo "Using base URL for master/main branch: $BRANCH_REPO_URL"
fi
echo "branch_repo_url=$BRANCH_REPO_URL" >> $GITHUB_OUTPUT
# Extract repository owner
REPO_OWNER=$(echo "$GITHUB_REPOSITORY" | cut -d'/' -f1)
echo "repo_owner=$REPO_OWNER" >> $GITHUB_OUTPUT
echo "Repository owner: $REPO_OWNER"
# Generate plugin input format: name@owner#branch (or name@owner if master/main)
REPO_NAME=$(basename $GITHUB_REPOSITORY)
if [[ "$BRANCH_NAME" != "master" && "$BRANCH_NAME" != "main" ]]; then
PLUGIN_INPUT="$REPO_NAME@$REPO_OWNER#$BRANCH_NAME"
else
PLUGIN_INPUT="$REPO_NAME@$REPO_OWNER"
fi
echo "plugin_input_format=$PLUGIN_INPUT" >> $GITHUB_OUTPUT
echo "Plugin input format: $PLUGIN_INPUT"
- name: Check if Latest Release
id: check_latest_release
run: |
BRANCH_NAME="${{ inputs.branch_name }}"
CURRENT_TAG="${{ inputs.tag_ref }}"
CURRENT_TAG="${CURRENT_TAG#refs/tags/}"
VERSION_NUMBER="${CURRENT_TAG#v}"
# Only master/main with non-0.x versions are latest releases
if [[ ("$BRANCH_NAME" == "master" || "$BRANCH_NAME" == "main") && ! "$VERSION_NUMBER" =~ ^0\. ]]; then
echo "is_latestrelease=true" >> $GITHUB_OUTPUT
else
echo "is_latestrelease=false" >> $GITHUB_OUTPUT
fi
echo "Branch: $BRANCH_NAME"
echo "Version: $VERSION_NUMBER"
echo "Is latest release: $(cat $GITHUB_OUTPUT | grep is_latestrelease | cut -d= -f2)"
- name: Get Commit Messages
id: get_commit_messages
run: |
# Get the current tag from inputs.tag_ref
CURRENT_TAG="${{ inputs.tag_ref }}"
CURRENT_TAG="${CURRENT_TAG#refs/tags/}"
echo "Current tag: $CURRENT_TAG"
# Get a list of all tags sorted by creation date (newest first)
ALL_TAGS=$(git tag --sort=-creatordate)
echo "All tags:"
echo "$ALL_TAGS"
# Find the previous tag (the tag after the current one in the sorted list)
PREVIOUS_TAG=""
FOUND_CURRENT=false
for tag in $ALL_TAGS; do
if $FOUND_CURRENT; then
PREVIOUS_TAG=$tag
break
fi
if [ "$tag" = "$CURRENT_TAG" ]; then
FOUND_CURRENT=true
fi
done
echo "Previous tag found: $PREVIOUS_TAG"
# Get commit messages between tags or all commits if no previous tag
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous tag found. Retrieving all commit messages."
COMMIT_MESSAGES=$(git log --pretty=format:"- %h %s" -n 20)
else
echo "Getting commits between $PREVIOUS_TAG and $CURRENT_TAG"
COMMIT_MESSAGES=$(git log --pretty=format:"- %h %s" $PREVIOUS_TAG..$CURRENT_TAG)
fi
# Check if we got any commit messages
if [ -z "$COMMIT_MESSAGES" ]; then
echo "No commit messages found!"
COMMIT_MESSAGES="- No significant changes detected between releases."
fi
# Debug: Show the commit messages
echo "Commit messages:"
echo "$COMMIT_MESSAGES"
# No need to base64 encode - we'll pass it directly
echo "commit_messages<> $GITHUB_OUTPUT
echo "$COMMIT_MESSAGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
================================================
FILE: .github/workflows/main.yml
================================================
name: Build Plugin
on:
push:
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
jobs:
# Detect branch from tag or reference
branch-detection:
uses: ./.github/workflows/branch-detection.yml
with:
github_ref: ${{ github.ref }}
# Extract metadata from the project
metadata:
uses: ./.github/workflows/metadata.yml
needs: [branch-detection]
with:
override_plugin_name: 'Tubifarry'
branch_name: ${{ needs.branch-detection.outputs.branch_name }}
# Extract git-specific information
git-info:
uses: ./.github/workflows/git-info.yml
needs: [branch-detection]
with:
tag_ref: ${{ github.ref }}
branch_name: ${{ needs.branch-detection.outputs.branch_name }}
# Setup .NET environment and submodules
dotnet-setup:
uses: ./.github/workflows/dotnet-setup.yml
with:
dotnet_version: '8.0.404'
framework: ${{ needs.metadata.outputs.framework }}
needs: [metadata]
# Compile the project
compilation:
uses: ./.github/workflows/compilation.yml
with:
dotnet_version: '8.0.404'
plugin_name: ${{ needs.metadata.outputs.plugin_name }}
package_version: ${{ needs.metadata.outputs.package_version }}
framework: ${{ needs.metadata.outputs.framework }}
git_commit: ${{ needs.git-info.outputs.git_commit }}
git_branch: ${{ needs.git-info.outputs.git_branch }}
git_tag: ${{ needs.git-info.outputs.git_tag }}
repo_url: ${{ needs.git-info.outputs.repo_url }}
branch_repo_url: ${{ needs.git-info.outputs.branch_repo_url }}
repo_owner: ${{ needs.git-info.outputs.repo_owner }}
minimum_lidarr_version: ${{ needs.metadata.outputs.minimum_lidarr_version }}
secrets:
SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }}
SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }}
YOUTUBE_SECRET: ${{ secrets.YOUTUBE_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
needs: [metadata, git-info, dotnet-setup]
# Package the artifacts
packaging:
uses: ./.github/workflows/packaging.yml
with:
plugin_name: ${{ needs.metadata.outputs.plugin_name }}
package_version: ${{ needs.metadata.outputs.package_version }}
build_suffix: ${{ needs.metadata.outputs.build_suffix }}
build_status: ${{ needs.compilation.outputs.build_status }}
needs: [metadata, compilation]
# Generate release notes
release-notes:
uses: ./.github/workflows/release-notes.yml
with:
plugin_name: ${{ needs.metadata.outputs.plugin_name }}
package_version: ${{ needs.metadata.outputs.package_version }}
build_suffix: ${{ needs.metadata.outputs.build_suffix }}
minimum_lidarr_version: ${{ needs.metadata.outputs.minimum_lidarr_version }}
commit_messages: ${{ needs.git-info.outputs.commit_messages }}
git_commit: ${{ needs.git-info.outputs.git_commit }}
branch_repo_url: ${{ needs.git-info.outputs.branch_repo_url }}
git_branch: ${{ needs.git-info.outputs.git_branch }}
plugin_input_format: ${{ needs.git-info.outputs.plugin_input_format }}
needs: [metadata, git-info]
# Publish the release
publishing:
uses: ./.github/workflows/publishing.yml
with:
plugin_name: ${{ needs.metadata.outputs.plugin_name }}
package_version: ${{ needs.metadata.outputs.package_version }}
build_suffix: ${{ needs.metadata.outputs.build_suffix }}
is_prerelease: ${{ needs.metadata.outputs.is_prerelease }}
is_latestrelease: ${{ needs.git-info.outputs.is_latestrelease }}
release_notes_id: ${{ needs.release-notes.outputs.release_notes_id }}
git_commit: ${{ needs.git-info.outputs.git_commit }}
branch_repo_url: ${{ needs.git-info.outputs.branch_repo_url }}
git_branch: ${{ needs.git-info.outputs.git_branch }}
needs: [metadata, git-info, compilation, packaging, release-notes]
if: needs.compilation.outputs.build_status == 'success'
================================================
FILE: .github/workflows/metadata.yml
================================================
name: Extract Project Metadata
on:
workflow_call:
inputs:
override_plugin_name:
required: false
type: string
description: "Override the plugin name if different from repository name"
default: ""
branch_name:
required: true
type: string
description: "Branch name (passed from main workflow)"
outputs:
plugin_name:
description: "The name of the plugin"
value: ${{ jobs.metadata.outputs.plugin_name }}
package_version:
description: "The version of the package"
value: ${{ jobs.metadata.outputs.package_version }}
build_suffix:
description: "Build suffix based on framework and branch name"
value: ${{ jobs.metadata.outputs.build_suffix }}
minimum_lidarr_version:
description: "Minimum Lidarr version required"
value: ${{ jobs.metadata.outputs.minimum_lidarr_version }}
is_prerelease:
description: "Whether this is a prerelease"
value: ${{ jobs.metadata.outputs.is_prerelease }}
framework:
description: "Framework version extracted from project file"
value: ${{ jobs.metadata.outputs.framework }}
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
plugin_name: ${{ steps.extract_repo_name.outputs.plugin_name }}
package_version: ${{ steps.extract_version.outputs.package_version }}
build_suffix: ${{ steps.build_info.outputs.build_suffix }}
branch_name: ${{ inputs.branch_name }}
framework: ${{ steps.extract_framework.outputs.framework }}
minimum_lidarr_version: ${{ steps.fetch_minimum_lidarr_version.outputs.minimum_lidarr_version }}
is_prerelease: ${{ steps.extract_version.outputs.is_prerelease }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 0
- name: Extract repository name or use override
id: extract_repo_name
run: |
# Check if override_plugin_name is provided
if [ -n "${{ inputs.override_plugin_name }}" ]; then
PLUGIN_NAME="${{ inputs.override_plugin_name }}"
echo "Using provided plugin name override: $PLUGIN_NAME"
else
# Fall back to repository name
PLUGIN_NAME=$(basename $GITHUB_REPOSITORY)
echo "Using repository name as plugin name: $PLUGIN_NAME"
fi
echo "plugin_name=$PLUGIN_NAME" >> $GITHUB_OUTPUT
- name: Extract framework from project file
id: extract_framework
run: |
# Get plugin name
PLUGIN_NAME="${{ steps.extract_repo_name.outputs.plugin_name }}"
echo "Looking for project file: $PLUGIN_NAME/$PLUGIN_NAME.csproj"
# Check if the main project file exists
if [ -f "$PLUGIN_NAME/$PLUGIN_NAME.csproj" ]; then
PROJECT_FILE="$PLUGIN_NAME/$PLUGIN_NAME.csproj"
else
echo "Main project file not found, searching for any .csproj file..."
# Find the first .csproj file in the repo
PROJECT_FILE=$(find . -name "*.csproj" | head -n 1)
fi
if [ -n "$PROJECT_FILE" ]; then
echo "Found project file: $PROJECT_FILE"
# Extract the TargetFramework using grep and sed
FRAMEWORK=$(grep -o '.*' "$PROJECT_FILE" | sed 's/\(.*\)<\/TargetFramework>/\1/')
echo "Extracted framework: $FRAMEWORK"
else
echo "No .csproj files found, defaulting to net6.0"
FRAMEWORK="net6.0"
fi
# If framework is still empty, default to net6.0
if [ -z "$FRAMEWORK" ]; then
echo "Framework not found in project file, defaulting to net6.0"
FRAMEWORK="net6.0"
fi
echo "Using framework: $FRAMEWORK"
echo "framework=$FRAMEWORK" >> $GITHUB_OUTPUT
- name: Extract version from tag
id: extract_version
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
# Remove branch suffix if present
if [[ "$TAG_VERSION" == *-* ]]; then
VERSION_ONLY=${TAG_VERSION%%-*}
echo "Extracted version without branch suffix: $VERSION_ONLY"
TAG_VERSION=$VERSION_ONLY
fi
echo "Extracted version: $TAG_VERSION"
echo "package_version=$TAG_VERSION" >> $GITHUB_OUTPUT
if [[ "$TAG_VERSION" == 0.* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build suffix
id: build_info
run: |
# Get the framework from the extract_framework step
FRAMEWORK="${{ steps.extract_framework.outputs.framework }}"
# Build suffix is just the framework
BUILD_SUFFIX="$FRAMEWORK"
echo "Framework: $FRAMEWORK"
echo "Build suffix: $BUILD_SUFFIX"
echo "build_suffix=$BUILD_SUFFIX" >> $GITHUB_OUTPUT
- name: Fetch Minimum Lidarr Version from Submodule
id: fetch_minimum_lidarr_version
run: |
if [ -f "Submodules/Lidarr/azure-pipelines.yml" ]; then
RAW_YAML=$(cat Submodules/Lidarr/azure-pipelines.yml)
else
echo "Error: Submodules/Lidarr/azure-pipelines.yml not found!"
exit 1
fi
MAJOR_VERSION=$(echo "$RAW_YAML" | grep "majorVersion:" | head -n 1 | sed "s/.*majorVersion: *'\([^']*\)'.*/\1/")
echo "Extracted majorVersion: $MAJOR_VERSION"
DOT_COUNT=$(echo "$MAJOR_VERSION" | awk -F. '{print NF-1}')
if [ "$DOT_COUNT" -eq 2 ]; then
MINIMUM_LIDARR_VERSION="${MAJOR_VERSION}.0"
else
MINIMUM_LIDARR_VERSION="$MAJOR_VERSION"
fi
echo "Minimum Lidarr Version: $MINIMUM_LIDARR_VERSION"
echo "minimum_lidarr_version=$MINIMUM_LIDARR_VERSION" >> $GITHUB_OUTPUT
================================================
FILE: .github/workflows/packaging.yml
================================================
name: Package Plugin
on:
workflow_call:
inputs:
plugin_name:
required: true
type: string
description: "Name of the plugin"
package_version:
required: true
type: string
description: "Version number for the package"
build_suffix:
required: true
type: string
description: "Build suffix based on framework and branch"
build_status:
required: true
type: string
description: "Status of the build step"
outputs:
package_path:
description: "Path to the final package"
value: ${{ jobs.package.outputs.package_path }}
jobs:
package:
runs-on: ubuntu-latest
outputs:
package_path: ${{ steps.zip_plugin.outputs.package_path }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download built plugin
uses: actions/download-artifact@v4
with:
name: plugin-output
path: ./plugin-output
# Don't continue on error - must have plugin to package
- name: Verify plugin artifacts are available
run: |
echo "Contents of the downloaded plugin output directory:"
find ./plugin-output -type f | sort
# Fail if no plugin files found
PLUGIN_FILES_COUNT=$(find ./plugin-output -name "*.${{ inputs.plugin_name }}.*" -o -name "*.Plugin.${{ inputs.plugin_name }}.*" | wc -l)
if [ "$PLUGIN_FILES_COUNT" -eq 0 ]; then
echo "::error::No plugin files found in downloaded artifacts! Build may have failed to produce plugin files."
exit 1
fi
echo "Found $PLUGIN_FILES_COUNT plugin files."
- name: Zip Plugin
id: zip_plugin
run: |
cd ./plugin-output
PACKAGE_NAME="${{ inputs.plugin_name }}-v${{ inputs.package_version }}.${{ inputs.build_suffix }}.zip"
# List files that will be included in the package
echo "Files to be packaged:"
find . -name "*.${{ inputs.plugin_name }}.*" -o -name "*.Plugin.${{ inputs.plugin_name }}.*"
# Create zip with all plugin files and release notes
find . -name "*.${{ inputs.plugin_name }}.*" -o -name "*.Plugin.${{ inputs.plugin_name }}.*" | xargs zip -r $PACKAGE_NAME
# Add release notes to the zip if it exists
if [ -f "release_notes.txt" ]; then
zip -r $PACKAGE_NAME release_notes.txt
fi
# Verify the zip was created and has content
if [ ! -f "$PACKAGE_NAME" ] || [ $(stat -c%s "$PACKAGE_NAME") -eq 0 ]; then
echo "::error::Failed to create package or package is empty!"
exit 1
fi
echo "Created package: $PACKAGE_NAME"
echo "Contents of zip file:"
unzip -l $PACKAGE_NAME
echo "package_path=./plugin-output/$PACKAGE_NAME" >> $GITHUB_OUTPUT
- name: Upload Package Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-package
path: ${{ steps.zip_plugin.outputs.package_path }}
retention-days: 7
================================================
FILE: .github/workflows/publishing.yml
================================================
name: Publish GitHub Release
on:
workflow_call:
inputs:
plugin_name:
required: true
type: string
description: "Name of the plugin"
package_version:
required: true
type: string
description: "Version number for the release"
build_suffix:
required: true
type: string
description: "Build suffix based on framework and branch"
is_prerelease:
required: true
type: string
description: "Whether this is a pre-release"
is_latestrelease:
required: true
type: string
description: "Whether this should be the newest stable release"
release_notes_id:
required: true
type: string
description: "ID of the generated release notes"
git_commit:
required: true
type: string
description: "Git commit SHA used for the build"
branch_repo_url:
required: false
type: string
description: "Repository URL with branch path"
default: ""
git_branch:
required: true
type: string
description: "Git branch name for target_commitish"
outputs:
release_url:
description: "URL of the created release"
value: ${{ jobs.publish.outputs.release_url }}
jobs:
publish:
runs-on: ubuntu-latest
outputs:
release_url: ${{ steps.create_release.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: plugin-package
path: ./artifacts
- name: Download release notes
uses: actions/download-artifact@v4
with:
name: ${{ inputs.release_notes_id }}
path: ./notes
- name: Verify artifacts
id: verify_artifacts
run: |
echo "Artifacts directory contents:"
find ./artifacts -type f | sort
# The artifact should follow the naming convention with framework from csproj
ARTIFACT_PATH="./artifacts/${{ inputs.plugin_name }}-v${{ inputs.package_version }}.${{ inputs.build_suffix }}.zip"
if [ ! -f "$ARTIFACT_PATH" ]; then
echo "::error::Artifact not found at expected path: $ARTIFACT_PATH"
exit 1
fi
echo "artifact_path=$ARTIFACT_PATH" >> $GITHUB_OUTPUT
FILESIZE=$(stat -c%s "$ARTIFACT_PATH")
echo "Artifact size: $FILESIZE bytes"
echo "artifact_size=$FILESIZE" >> $GITHUB_OUTPUT
# Check release notes
if [ ! -f "./notes/release_notes.md" ]; then
echo "::error::Release notes not found at expected path: ./notes/release_notes.md"
exit 1
fi
echo "release_notes_path=./notes/release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
id: create_release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref }}
name: "🚀 Release ${{ inputs.package_version }}"
body_path: ${{ steps.verify_artifacts.outputs.release_notes_path }}
prerelease: ${{ inputs.is_prerelease == 'true' }}
make_latest: ${{ inputs.is_latestrelease == 'true' }}
target_commitish: ${{ inputs.git_branch }}
files: |
${{ steps.verify_artifacts.outputs.artifact_path }}
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Verify release was created
run: |
echo "Release URL: ${{ steps.create_release.outputs.url }}"
if [ -z "${{ steps.create_release.outputs.url }}" ]; then
echo "::warning::Release URL is empty, there might have been an issue with release creation"
else
echo "Release successfully created"
fi
- name: Notify of successful release
if: success()
run: |
echo "::notice::🎉 Successfully published release v${{ inputs.package_version }} for ${{ inputs.plugin_name }}"
# Include branch-specific URL in notification if available
if [ -n "${{ inputs.branch_repo_url }}" ]; then
echo "Repository: ${{ inputs.branch_repo_url }}"
fi
echo "Release available at: ${{ steps.create_release.outputs.url }}"
================================================
FILE: .github/workflows/release-notes.yml
================================================
name: Generate Release Notes
on:
workflow_call:
inputs:
plugin_name:
required: true
type: string
description: "Name of the plugin"
package_version:
required: true
type: string
description: "Version number for the release"
build_suffix:
required: true
type: string
description: "Build suffix based on framework and branch"
minimum_lidarr_version:
required: true
type: string
description: "Minimum required Lidarr version"
commit_messages:
required: true
type: string
description: "Commit messages since last release"
git_commit:
required: true
type: string
description: "Git commit SHA used for the build"
branch_repo_url:
required: true
type: string
description: "Repository URL with branch path"
git_branch:
required: true
type: string
description: "Current git branch"
plugin_input_format:
required: true
type: string
description: "Plugin input format: name@owner#branch"
outputs:
release_notes_id:
description: "ID of the generated release notes"
value: ${{ jobs.generate.outputs.release_notes_id }}
jobs:
generate:
runs-on: ubuntu-latest
outputs:
release_notes_id: ${{ steps.create_notes.outputs.release_notes_id }}
steps:
- name: Generate Release Notes
id: create_notes
run: |
# Generate a unique ID for this release notes
NOTES_ID="release-notes-${{ github.run_id }}"
echo "release_notes_id=$NOTES_ID" >> $GITHUB_OUTPUT
# Print the commit messages for debugging
echo "Commit messages:"
echo "${{ inputs.commit_messages }}"
# Use the git_branch input directly
BRANCH_NAME="${{ inputs.git_branch }}"
echo "Using branch from git-info: $BRANCH_NAME"
# Set default branch
DEFAULT_BRANCH="master"
# Generate release notes in markdown
cat > release_notes.md << EOL
## 📝 What's New:
${{ inputs.commit_messages }}
---
## 📥 Installation Notes:
- In Lidarr, navigate to **System -> Plugins**
- Enter: \`${{ inputs.plugin_input_format }}\`
---
## 📦 Package Information
**Version:** ${{ inputs.package_version }}
**.NET Version:** ${{ inputs.build_suffix }}
**Minimum Lidarr Version:** ${{ inputs.minimum_lidarr_version }}
**Commit:** ${{ inputs.git_commit }}
EOL
# Debug: Show what was generated
echo "Generated release notes:"
cat release_notes.md
- name: Upload Release Notes
uses: actions/upload-artifact@v4
with:
name: ${{ steps.create_notes.outputs.release_notes_id }}
path: release_notes.md
retention-days: 1
================================================
FILE: .gitignore
================================================
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
_plugins/
_tests/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/_plugins/SpotiarrTube
/_plugins/Tubifarry
/enc_temp_folder/f7b6bdaa49df014838e55dd376c78df
/_plugins/UnitTest
/enc_temp_folder
================================================
FILE: .gitmodules
================================================
[submodule "Submodules/Lidarr"]
path = Submodules/Lidarr
url = https://github.com/Lidarr/Lidarr/
================================================
FILE: CONTRIBUTION.md
================================================
# Contributing to Tubifarry
Thank you for your interest in contributing to Tubifarry! This guide
will help you set up your development environment and understand our
contribution process.
Tubifarry is a plugin for Lidarr that extends its functionality.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Understanding the Architecture](#understanding-the-architecture)
- [Automatic Setup (Recommended)](#automatic-setup-recommended)
- [Build Automation Explained](#build-automation-explained)
- [Manual Setup (Fallback)](#manual-setup-fallback)
- [Development Workflow](#development-workflow)
- [Contributing Code](#contributing-code)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Troubleshooting](#troubleshooting)
- [Getting Help](#getting-help)
## Prerequisites
Before you begin, ensure you have the following installed on your system:
### Required Software
- **Visual Studio 2022** or higher (Community edition is free)
- Download: https://visualstudio.com/downloads/
- Must include .NET 8 SDK (VS 2022 V17.0+)
- **Git** - Version control system
- **Node.js 20.x** - JavaScript runtime for frontend development
- Download: https://nodejs.org/
- ⚠️ **Important**: Versions 18.x, 16.x, or 21.x will NOT work
- **Yarn** - Package manager for Node.js dependencies
- Included with Node 20+ (enable with `corepack enable`)
- For other versions: `npm i -g corepack`
### Optional but Recommended
- **Rider** by JetBrains (alternative to Visual Studio)
- **VS Code** or similar text editor for quick edits
### System Requirements
- **Windows**: Windows 10/11
- **Linux**: Any modern distribution
- **macOS**: 10.14+ (Mojave or newer)
- **RAM**: Minimum 8GB (16GB recommended)
- **Storage**: At least 5GB free space for the full build
## Understanding the Architecture
Tubifarry is built as a plugin that integrates with Lidarr's core functionality:
- **Lidarr Core**: The base music management application (included as a Git submodule)
- **Tubifarry Plugin**: Your plugin code that extends Lidarr
- **Frontend**: React-based UI (part of Lidarr)
- **Backend**: C# .NET 8 code (both Lidarr and Tubifarry)
### Important Notes About the Build Process
1. **Lidarr Submodule**: Tubifarry includes Lidarr as a Git submodule, which means it contains a reference to a specific version of Lidarr
2. **Force Push Warning**: The Lidarr develop branch is regularly rebased and force-pushed. This is normal and expected behavior
3. **Build Order Matters**: You must build Lidarr completely before building Tubifarry
4. **First Build Takes Time**: The initial setup can take 5-10 minutes, but subsequent builds are much faster
## Automatic Setup (Recommended)
The project includes build automation that handles most of the setup process automatically. This is the fastest way to get started.
### Step 1: Clone the Repository
```bash
git clone https://github.com/TypNull/Tubifarry.git
```
Or if you've forked the repository:
```bash
git clone https://github.com/YOUR-USERNAME/Tubifarry.git
```
### Step 2: Open and Build
1. **Open** `Tubifarry.sln` in Visual Studio
2. **Build the solution** by pressing `Ctrl + Shift + B` (or `Build` → `Build Solution`)
⏳ **Wait patiently** - This first build will take some time depending on your internet connection and PC performance. The build automation will:
- Initialize and clone all Git submodules (Lidarr source code)
- Install Node.js dependencies via Yarn
- Build the Lidarr frontend
You can monitor progress in the **Output** window (`View` → `Output`).
⚠️ You will see errors on this build.
### Step 3: Restart Visual Studio
After the first build completes:
1. **Close** Visual Studio completely
2. **Reopen** Visual Studio
3. **Open** `Tubifarry.sln` again
This ensures Visual Studio properly recognizes all the newly generated files and references.
### Step 4: Build Again
1. **Build the solution** again (`Ctrl + Shift + B`)
⚠️ You may see some errors on this build - **these can be ignored**.
2. **Build once more** (`Ctrl + Shift + B`)
The build should now complete successfully.
### Step 5: Configure Startup Project
1. In **Solution Explorer**, navigate to: `Submodules` → `Lidarr` → find `Lidarr.Console`
2. **Right-click** on `Lidarr.Console`
3. Select **"Set as Startup Project"**
### Step 6: Run
Press `F5` or click the **Start** button to run Lidarr.
---
**That's it!** If everything worked, you're ready to start developing. If you encountered errors, see the [Manual Setup](#manual-setup-fallback) or [Troubleshooting](#troubleshooting) sections below.
## Build Automation Explained
The project uses several MSBuild `.targets` files to automate the setup and build process. Understanding these can help with troubleshooting.
### PreBuild.targets - Automatic Initialization
This file handles automatic setup before the main build starts:
**Submodule Initialization (`InitializeSubmodules` target)**
- Checks if the Lidarr submodule exists by looking for `Submodules/Lidarr/src/NzbDrone.Core/Lidarr.Core.csproj`
- If missing, automatically runs `git submodule update --init --recursive`
**Frontend Build (`LidarrFrontendBuild` target)**
- Checks if the Lidarr frontend has been built by looking for `Submodules/Lidarr/_output/UI/`
- If missing, automatically runs:
- `yarn install` - Downloads all Node.js dependencies
- `yarn build` - Compiles the React frontend
- This can take several minutes on first run
### ILRepack.targets - Assembly Merging
After the build completes, this file merges all plugin dependencies into a single DLL:
### Debug.targets - Automatic Plugin Deployment
In Debug configuration, this file automatically deploys your plugin:
- After ILRepack finishes, copies the plugin files to Lidarr's plugin directory:
- `C:\ProgramData\Lidarr\plugins\AUTHOR\Tubifarry\`
- Allows immediate testing without manual file copying
### Build Order
The targets execute in this order:
1. `InitializeSubmodules` → Ensures Lidarr source exists
2. `LidarrFrontendBuild` → Ensures frontend is compiled
3. `BeforeBuild` → Standard .NET build preparation
4. `Build` → Compiles Tubifarry code
5. `CopySystemTextJson` → Copies required runtime DLL
6. `ILRepacker` → Merges all assemblies
7. `PostBuild` (Debug only) → Deploys to plugin folder
## Manual Setup (Fallback)
If the automatic setup fails or you prefer manual control, follow these steps.
### Step 1: Fork and Clone
1. **Fork the Repository**
- Go to https://github.com/TypNull/Tubifarry
- Click the "Fork" button in the top right
- This creates your own copy of the repository
2. **Clone Your Fork**
```bash
git clone https://github.com/YOUR-USERNAME/Tubifarry.git
cd Tubifarry
```
### Step 2: Initialize Git Submodules
The Tubifarry repository includes Lidarr as a submodule. You need to initialize and download it:
```bash
git submodule update --init --recursive
```
This command downloads the Lidarr source code into the submodule directory. This may take a few minutes depending on your internet connection.
### Step 3: Verify Submodule
Check that the Lidarr submodule was properly initialized:
```bash
cd Submodules/Lidarr/
git status
```
You should see the Lidarr repository files.
### Step 4: Build Lidarr Frontend
The frontend must be built before proceeding to the backend.
1. **Navigate to the Lidarr submodule directory**
```bash
cd Submodules/Lidarr/
```
2. **Install Node dependencies**
```bash
yarn install
```
This downloads all required JavaScript packages. First time takes 2-5 minutes.
3. **Build the frontend** (or start the watcher for development)
```bash
yarn build
```
Or for active development with hot-reload:
```bash
yarn start
```
**Important**: If using `yarn start`, keep this terminal window open.
### Step 5: Build Lidarr Backend
1. **Open the Lidarr solution in Visual Studio**
- Navigate to the Lidarr submodule directory
- Open `Lidarr.sln` in Visual Studio 2022
2. **Configure the startup project**
- Right-click on `Lidarr.Console` in Solution Explorer
- Select "Set as Startup Project"
3. **Build the solution**
- Click `Build` → `Build Solution` (or press `Ctrl+Shift+B`)
- Wait for the build to complete (first build takes a bit time)
- Watch the Output window for any errors
### Step 6: Build Tubifarry Plugin
Now that Lidarr is fully built, you can build the Tubifarry plugin.
1. **Navigate back to the Tubifarry root directory**
```bash
cd [path-to-Tubifarry-root]
```
2. **Open the Tubifarry solution**
- Open `Tubifarry.sln` in Visual Studio (in a new instance or after closing Lidarr solution)
3. **Wait for dependencies to load**
- Visual Studio will restore NuGet packages automatically
- Wait until the status bar shows "Ready"
4. **Build the Tubifarry solution**
- Click `Build` → `Build Solution`
- The plugin will automatically copy to the correct directory on Windows in Debug mode
### Step 7: Manual Plugin Deployment (if needed)
If the automatic deployment doesn't work, manually copy the plugin:
1. Find the built plugin at: `Tubifarry/bin/Debug/net8.0/Lidarr.Plugin.Tubifarry.dll`
2. Copy to: `C:\ProgramData\Lidarr\plugins\AUTHOR\Tubifarry\`
3. Also copy the `.pdb` and `.deps.json` files if they exist
## Development Workflow
### Daily Development Process
Once you've completed the initial setup, your typical workflow will be:
1. **Make your changes** to Tubifarry code in Visual Studio
2. **Build Tubifarry** to test your changes (`Ctrl + Shift + B`)
3. **Run and test**
- Ensure `Lidarr.Console` is set as the startup project
- Press `F5` to start debugging
- Lidarr will start with your plugin loaded
- Access the UI at http://localhost:8686
### Hot Reload (Optional)
For faster frontend iteration:
1. Keep `yarn start` running in the Lidarr submodule directory
2. Frontend changes will automatically refresh in the browser
## Contributing Code
### Before You Start
- **Check existing issues**: Look at [GitHub Issues](https://github.com/TypNull/Tubifarry/issues) to see if your feature/bug is already being worked on
- **Create an issue**: If your idea isn't already tracked, create a new issue to discuss it
- **Ask questions**: Join our Discord or comment on the issue if you need clarification
### Code Guidelines
1. **Code Style**
- Use 4 spaces for indentation (not tabs)
- Follow C# naming conventions
- Keep methods focused and reasonably sized
- Add XML documentation comments for public APIs
2. **Commit Guidelines**
- Make meaningful commits with clear messages
- Use \*nix line endings (LF, not CRLF)
- Commit message format:
- `New: Add Spotify playlist import feature`
- `Fixed: YouTube download timeout issue`
- `Improved: Error handling in Slskd client`
3. **Code Quality**
- Test your changes thoroughly
- Handle errors gracefully
- Add logging for debugging purposes
- Don't leave commented-out code
### Branching Strategy
1. **Create a feature branch** from `develop` (not from `master`)
```bash
git checkout develop
git pull origin develop
git checkout -b feature/your-feature-name
```
2. **Use descriptive branch names**
- ✅ Good: `feature/spotify-auth`, `fix/youtube-timeout`, `improve/error-messages`
- ❌ Bad: `patch`, `updates`, `my-branch`
3. **Keep your branch updated**
```bash
git checkout develop
git pull origin develop
git checkout feature/your-feature-name
git rebase develop
```
## Pull Request Guidelines
### Before Submitting
- [ ] Code builds without errors
- [ ] You've tested the changes locally
- [ ] Code follows the style guidelines
- [ ] Commits are clean and well-organized (consider squashing if needed)
- [ ] Branch is up to date with `develop`
### Creating a Pull Request
1. **Push your branch** to your fork
```bash
git push origin feature/your-feature-name
```
2. **Open a Pull Request** on GitHub
- Go to your fork on GitHub
- Click "Compare & pull request"
- Target the `develop` branch (not `master`)
3. **Fill out the PR template**
- Describe what your PR does
- Reference any related issues (e.g., "Fixes #123")
- Add screenshots if it's a UI change
- List any breaking changes
4. **Respond to feedback**
- Maintainers may request changes
- Make the requested updates in your branch
- Push the changes (they'll automatically update the PR)
### PR Review Process
- We aim to review PRs within a few days
- If it's been longer, feel free to ping us on Discord
- Be patient and respectful during code review
- All PRs require approval before merging
### Important Rules
- ⛔ **Never** make PRs to `master` - they will be closed
- ⛔ **Don't** merge `develop` into your feature branch - use rebase instead
- ✅ **Do** create one PR per feature/bugfix
- ✅ **Do** ask questions if you're unsure
## Troubleshooting
### Common Build Errors
#### "Could not load file or assembly 'Lidarr.Core'"
**Cause**: Lidarr backend wasn't fully built before building Tubifarry.
**Solution**:
1. Ensure Lidarr frontend is built (`Submodules/Lidarr/_output/UI/` exists)
2. Open and build `Lidarr.sln` first
3. Then build Tubifarry
#### "Node version not supported" or "Yarn command not found"
**Cause**: Wrong Node.js version or Yarn not enabled.
**Solution**:
```bash
# Check Node version
node --version # Should be 20.x
# Enable Yarn
corepack enable
# Verify Yarn is available
yarn --version
```
#### Frontend changes not appearing
**Cause**: Frontend not rebuilt or browser cache.
**Solution**:
1. Navigate to Lidarr submodule directory
2. Run `yarn build` (or `yarn start` for development)
3. Refresh your browser with `Ctrl+F5` (hard refresh)
#### "Submodule not initialized" or empty Lidarr directory
**Cause**: Git submodules weren't initialized.
**Solution**:
```bash
git submodule update --init --recursive
```
#### Build succeeds but plugin doesn't appear in Lidarr
**Cause**: Plugin DLL not copied to correct location.
**Solution**:
1. Check if you're building in **Debug** configuration (automatic copy only works in Debug)
2. Check your build output directory: `Tubifarry/bin/Debug/net8.0/`
3. Manually copy plugin files to: `C:\ProgramData\Lidarr\plugins\AUTHOR\Tubifarry\`
4. Restart Lidarr
5. Check Lidarr logs for plugin loading errors
#### ILRepack errors
**Cause**: Missing or incompatible assemblies.
**Solution**:
1. Clean the solution (`Build` → `Clean Solution`)
2. Delete the `bin` and `obj` folders in the Tubifarry project
3. Rebuild
### Performance Issues
#### First build is very slow
This is normal! The first build includes:
- Downloading all NuGet packages
- Downloading all Node packages
- Building the frontend (webpack compilation)
- Building everything from scratch
Subsequent builds will be much faster (usually under 1 minute).
#### Visual Studio is slow or freezes
**Solutions**:
- Close unnecessary programs to free up RAM
- Disable unnecessary VS extensions
- Clear VS cache: `Tools` → `Options` → `Projects and Solutions` → `Build and Run` → check "Only build startup projects"
- Consider using Rider instead (lighter weight)
### Getting More Help
If you're still stuck:
1. **Check existing issues**: https://github.com/TypNull/Tubifarry/issues
2. **Search Discord**: Past questions may have been answered
3. **Ask on Discord**: Include:
- What you're trying to do
- What error you're seeing
- What you've already tried
- Your OS and software versions
## Getting Help
### Support Channels
- **GitHub Issues**: For bug reports and feature requests
- **Servarr Discord**: For development questions and discussions
### Tips for Getting Help
When asking for help, please include:
1. **What you're trying to accomplish**
2. **What error you're encountering** (exact error message)
3. **What you've already tried**
4. **Your environment**:
- Operating System
- Visual Studio version
- .NET SDK version (`dotnet --version`)
- Node.js version (`node --version`)
---
## Quick Reference
### First-Time Setup Checklist
- [ ] Install Visual Studio 2022 with .NET 8 SDK
- [ ] Install Git
- [ ] Install Node.js 20.x
- [ ] Enable Yarn (`corepack enable`)
- [ ] Fork Tubifarry repository
- [ ] Clone your fork
- [ ] Initialize submodules (`git submodule update --init --recursive`)
- [ ] Build Lidarr frontend (`cd Submodules/Lidarr && yarn install && yarn build`)
- [ ] Build Lidarr backend (open `Lidarr.sln`, build)
- [ ] Build Tubifarry plugin (open `Tubifarry.sln`, build)
- [ ] Test by running Lidarr.Console
### Before Submitting a PR
- [ ] Code builds successfully
- [ ] Changes tested locally
- [ ] Commits are clean with good messages
- [ ] Branch rebased with latest `develop`
- [ ] PR targets `develop` (not `master`)
- [ ] PR description is complete
---
Thank you for contributing to Tubifarry! Your contributions help make
music management better for everyone.
================================================
FILE: Directory.Build.props
================================================
AnyCPUtrue$(MSBuildThisFileDirectory)LibraryTestExeExeExeExeUpdatefalsetruetruetruetrueRelease$(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\$(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\$(Configuration)\$(LidarrRootDir)_temp\bin\$(Configuration)\$(MSBuildProjectName)\$(LidarrRootDir)_output\$(LidarrRootDir)_plugins\$(TargetFramework)\$(MSBuildProjectName)$(LidarrRootDir)_tests\$(LidarrRootDir)_output\Lidarr.Update\$([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(BaseIntermediateOutputPath)'))$([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)'))$([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(OutputPath)'))truetruetruefalse
<_Parameter1>$(AssemblyConfiguration)
false$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))allruntime; build; native; contentfiles; analyzers
================================================
FILE: LICENSE.txt
================================================
MIT License
Copyright (c) 2026 TypNull
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: NuGet.config
================================================
================================================
FILE: README.md
================================================
# Tubifarry for Lidarr 🎶
    
Tubifarry is a plugin for **Lidarr** that adds multiple music sources to your library management. It uses **Spotify's catalog** as an [indexer](https://wiki.servarr.com/en/lidarr/supported#indexers) to search for music, then downloads the actual audio files from **YouTube**. Tubifarry also supports **Slskd**, the Soulseek client, as both an **indexer** and **downloader**, allowing you to tap into the vast music collection available on the Soulseek network. 🛠️
Additionally, Tubifarry supports fetching soundtracks from **Sonarr** (series) and **Radarr** (movies) and adding them to Lidarr using the **Arr-Soundtracks** import list feature. This makes it easy to manage and download soundtracks for your favorite movies and TV shows. 🎬🎵
For further customization, Codec Tinker lets you convert audio files between formats using FFmpeg, helping you optimize your library.⚙️
> **Note**: Some details in this documentation may vary from the current implementation.
---
## Table of Contents 📑
1. [Installation 🚀](#installation-)
2. [Soulseek (Slskd) Setup 🎧](#soulseek-slskd-setup-)
3. [YouTube Downloader Setup 🎥](#youtube-downloader-setup-)
4. [WebClients 📻](#web-clients-)
5. [Fetching Soundtracks 🎬🎵](#fetching-soundtracks-from-sonarr-and-radarr-)
6. [Queue Cleaner 🧹](#queue-cleaner-)
7. [Codec Tinker 🎛️](#codec-tinker-️)
8. [Lyrics Fetcher 📜](#lyrics-fetcher-)
9. [Search Sniper 🏹](#search-sniper-)
10. [Custom Metadata Sources 🧩](#custom-metadata-sources-)
11. [Similar Artists 🧷](#similar-artists-)
12. [Troubleshooting 🛠️](#troubleshooting-%EF%B8%8F)
----
## Installation 🚀
Follow the steps below to get started.
- In Lidarr, go to `System -> Plugins`.
- Paste `https://github.com/TypNull/Tubifarry` into the GitHub URL box and click **Install**.
---
### Soulseek (Slskd) Setup 🎧
Tubifarry supports **Slskd**, the Soulseek client, as both an **indexer** and **downloader**. Follow the steps below to configure it.
#### **Setting Up the Soulseek Indexer**:
1. Navigate to `Settings -> Indexers` and click **Add**.
2. Select `Slskd` from the list of indexers.
3. Configure the following settings:
- **URL**: The URL of your Slskd instance (e.g., `http://localhost:5030`).
- **API Key**: The API key for your Slskd instance (found in Slskd's settings under 'Options').
- **Include Only Audio Files**: Enable to filter search results to audio files only.
#### **Setting Up the Soulseek Download Client**:
1. Go to `Settings -> Download Clients` and click **Add**.
2. Select `Slskd` from the list of download clients.
3. The download path is fetched from slskd, if it does not match use `Remote Path` settings.
---
### YouTube Downloader Setup 🎥
> #### YouTube Bot Detection ⚠️
> YouTube actively detects and blocks automated downloaders. To bypass this, configure the Trusted Session Generator and provide cookie authentication (see setup steps below).
The YouTube downloader extracts audio from YouTube and converts them to audio files using FFmpeg.
#### **Configure the Indexer**:
1. Navigate to `Settings -> Indexers` and click **Add**.
2. In the modal, select `Tubifarry` (located under **Other** at the bottom).
#### **Setting Up the YouTube Download Client**:
1. Go to `Settings -> Download Clients` and click **Add**.
2. Select `Youtube` from the list of download clients.
3. Set the download path and adjust other settings as needed.
4. **Optional**: If using FFmpeg, ensure the FFmpeg path is correctly configured.
#### **FFmpeg and Audio Conversion**:
1. **FFmpeg**: Required to extract audio from YouTube. The plugin will attempt to download FFmpeg automatically if not found. Without FFmpeg, files will be downloaded in their original format, which Lidarr may cannot properly import.
- Ensure FFmpeg is in your system PATH or specify its location in settings
- Used for extracting audio tracks and converting between formats
2. **Audio Quality**: YouTube provides audio at various bitrates:
- Standard quality: 128kbps AAC (free users)
- High quality: 256kbps AAC (YouTube Premium required)
- The plugin can convert to other formats (MP3, Opus) using FFmpeg
---
### Web Clients 📻
Tubifarry supports multiple web clients. These are web services that provide music. Some work better than others and Tubifarry is not responsible for the uptime or stability of these services.
##### Supported Clients
- **Lucida** - A music downloading service that supports multiple sources.
- **DABmusic** - A high-resolution audio streaming platform.
- **T2Tunes** - A music downloading service that supports AmazonMusic
- **Subsonic** - A music streaming API standard with broad compatibility
All clients share the same base architecture, making it relatively straightforward to add new ones. The Subsonic Indexer and Client is a generic client, making it possible for any online service to connect with it. The Subsonic specifications are documented on the [API page](https://www.subsonic.org/pages/api.jsp).
If you have a suggestion to add a web client and the service does not want to support Subsonic as a generic indexer, please open a feature request.
---
### Fetching Soundtracks from Sonarr and Radarr 🎬🎵
Tubifarry also supports fetching soundtracks from **Sonarr** (for TV series) and **Radarr** (for movies) and adding them to Lidarr using the **Arr-Soundtracks** import list feature. This allows you to easily manage and download soundtracks for your favorite movies and TV shows.
To enable this feature:
1. **Set Up the Import List**:
- Navigate to `Settings -> Import Lists` in Lidarr.
- Add a new import list and select the option for **Arr-Soundtracks**.
- Configure the settings to match your Sonarr and Radarr instances.
- Provide a cache path to store responses from MusicBrainz for faster lookups.
2. **Enjoy Soundtracks**:
- Once configured, Tubifarry will automatically fetch soundtracks from your Sonarr and Radarr libraries and add them to Lidarr for download and management.
---
### Queue Cleaner 🧹
The **Queue Cleaner** automatically handles downloads that fail to import into your library. When Lidarr can't import a download (due to missing tracks, incorrect metadata, etc.), Queue Cleaner can rename files based on their embedded tags, retry the import, blocklist the release, or remove the files.
1. **Key Options**:
- *Blocklist*: Choose to remove, blocklist, or both for failed imports.
- *Rename*: Automatically rename album folders and tracks using available metadata.
- *Clean Imports*: Decide when to clean—when tracks are missing, metadata is incomplete, or always.
- *Retry Finding Release*: Automatically retry searching for a release if the import fails.
2. **How to Enable**:
- Navigate to `Settings -> Connect` in Lidarr.
- Add a new connection and select the **Queue Cleaner**.
- Configure the settings to match your needs.
---
### Codec Tinker 🎛️
**Codec Tinker** automatically converts audio files between formats using FFmpeg when they're imported into your library. You can set up rules to convert specific formats (e.g., convert all WAV files to FLAC, or convert high-bitrate AAC to MP3). Note: Lossy formats (MP3, AAC) cannot be converted to lossless formats (FLAC, WAV) as quality cannot be restored.
#### How to Enable Codec Tinker
1. Go to `Settings > Metadata` in Lidarr.
2. Open the **Codec Tinker** MetadataConsumer.
3. Toggle the switch to enable the feature.
#### How to Use Codec Tinker
1. **Set Default Conversion Settings**
- **Target Format**:
Choose the default format for conversions (e.g., FLAC, Opus, MP3).
- **Custom Conversion Rules**:
Define rules like `wav -> flac`, `AAC>=256k -> MP3:300k` or `all -> alac` for more specific conversions.
- **Custom Conversion Rules On Artists**:
Define tags like `opus-192` for one specific conversion on all albums of an artist.
**Note**: Lossy formats (e.g., MP3, AAC) cannot be converted to non-lossy formats (e.g., FLAC, WAV).
2. **Enable Format-Specific Conversion**
Toggle checkboxes or use custom rules to enable conversion for specific formats:
- **Convert MP3**, **Convert FLAC**, etc.
---
### Lyrics Fetcher 📜
**Lyrics Fetcher** automatically downloads lyrics for your music files. It fetches synchronized lyrics from LRCLIB and plain lyrics from Genius. Lyrics can be saved as separate .lrc files and embedded directly into the audio files' metadata.
#### How to Enable Lyrics Fetcher
1. Go to `Settings > Metadata` in Lidarr.
2. Open the **Lyrics Fetcher** MetadataConsumer.
3. Toggle the switch to enable the feature.
#### How to Use Lyrics Fetcher
You can configure the following options:
- **Create LRC Files**: Enables creating external `.lrc` files that contain time-synced lyrics.
- **Embed Lyrics in Audio Files**: Instead of (or in addition to) creating separate LRC files, this option embeds the lyrics directly into the audio file's.
- **Overwrite Existing LRC Files**: When enabled, this will replace any existing LRC files with newly downloaded ones.
---
### Search Sniper 🏹
**Search Sniper** automatically triggers searches for missing albums in your wanted list. Instead of searching for everything at once, which can overload indexers, it randomly selects a few albums from your wanted list at regular intervals and searches for them. It keeps track of what has been searched recently to avoid repeating searches too often.
Search Sniper can be triggered manually from the Tasks tab.
#### How to Enable Search Sniper
1. Go to `Settings > Metadata` in Lidarr.
2. Open the **Search Sniper** option.
3. Configure the following options:
- **Picks Per Interval**: How many items to search each cycle
- **Min Refresh Interval**: How often to run searches
- **Cache Type**: Memory or Permanent
- **Cache Retention Time**: Days to keep cache
- **Pause When Queued**: Stop when queue reaches this number
- **Search Options**: Enable at least one - Missing albums, Missing tracks, or Cutoff not met
---
### Custom Metadata Sources 🧩
Tubifarry can fetch metadata from additional sources beyond MusicBrainz, including **Discogs**, **Deezer**, and **Last.fm**. These sources can provide additional artist information, album details, and cover art when MusicBrainz data is incomplete. The MetaMix feature intelligently combines data from multiple sources to create more complete metadata profiles.
#### How to Enable Individual Metadata Sources
1. Go to `Settings > Metadata` in Lidarr.
2. Open a specific **Metadata Source**.
3. Toggle the switch to enable the feature.
4. Configure the required settings:
- **User Agent**: Set a custom identifier that follows the format "Name/Version" to help the metadata service identify your requests properly.
- **API Key**: Enter your personal access token or API key for the service.
- **Caching Method**: Choose between:
- **Memory Caching**: Faster but less persistent (only recommended if your system has been running stably for 5+ days)
- **Permanent Caching**: More reliable but requires disk storage
- **Cache Directory**: If using Permanent caching, specify a folder where metadata can be stored to reduce API calls.
#### How to Enable Multiple Metadata Sources
MetaMix is an advanced feature that intelligently combines metadata from multiple sources to create more complete artist profiles. It can fill gaps in one source with information from another, resulting in a more comprehensive music library.
1. Go to `Settings > Metadata` in Lidarr.
2. Open the **MetaMix** settings.
3. Configure the following options:
- **Priority Rules**: Establish a hierarchy among your metadata sources. For example, set MusicBrainz as primary and Discogs as secondary. Lower numbers indicate higher priority.
- **Dynamic Threshold**: Controls how aggressively MetaMix switches between sources:
- Higher values make MetaMix more willing to use lower-priority sources
- Lower values make MetaMix stick more closely to your primary source
- **Multi-Source Population**: When enabled, missing album information from your primary source will be automatically supplemented with data from secondary sources.
The feature currently works best with artists that are properly linked across different metadata systems. Which is typically the case on MusicBrainz.
---
### Similar Artists 🧷
**Similar Artists** lets you discover related artists using Last.fm's
recommendation data directly in Lidarr's search. Search for an artist
with the `~` prefix and get back a list of similar musicians ready to
be added to your library.
#### How to Enable Similar Artists
1. Go to `Settings > Metadata` in Lidarr.
2. Enable these three metadata sources:
- **Similar Artists** - Enter your Last.fm API key
- **Lidarr Default** - Required to handle normal searches
- **MetaMix** - Required to coordinate the search
3. Optional: Adjust result limit, enable image fetching, and configure caching.
**Examples:**
- `similar:Pink Floyd`
- `~20244d07-534f-4eff-b4d4-930878889970`
---
## Troubleshooting 🛠️
- **Slskd Download Path Permissions**:
Ensure Lidarr has read/write access to the Slskd download path. Verify folder permissions and confirm the user running Lidarr has the necessary access. For Docker setups, double-check that the volume is correctly mounted and permissions are properly configured.
- **FFmpeg Issues (Optional)**:
If you're using FFmpeg and songs fail to process, ensure FFmpeg is installed correctly and accessible in your system's PATH. If issues persist, try reinstalling FFmpeg or downloading it manually.
- **Metadata Issues**:
If metadata isn't being added to downloaded files, confirm the files are in a supported format. If using FFmpeg, check that it's extracting audio to compatible formats like AAC embedded in MP4 containers. Review debug logs for further details.
- **No Release Found**:
If no release is found, YouTube may flag the plugin as a bot. To avoid this and access higher-quality audio, use a combination of cookies and the Trusted Session Generator:
1. Install the **cookies.txt** extension for your browser:
- [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)
2. Log in to YouTube and save the `cookies.txt` file in a folder accessible by Lidarr.
3. In Lidarr, go to **Indexer and Downloader Settings** and provide the path to the `cookies.txt` file.
4. **Trusted Session Generator**: Creates authentication tokens that mimic regular browser sessions to bypass YouTube's bot detection.
- It generates tokens locally, which requires Node.js installed and available in your system's PATH
- It can generate tokens using the [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
The combination of cookies and trusted sessions significantly improves success rates when downloading from YouTube, and can help access higher quality audio streams.
- **No Lyrics Imported**:
To save `.lrc` files (lyric files), navigate to **Media Management > Advanced Settings > Import Extra Files** and add `lrc` to the list of supported file types. This ensures lyric files are imported and saved alongside your music files.
- **Unsupported Formats**: Verify custom rules and target formats.
---
## Acknowledgments 🙌
Special thanks to [**trevTV**](https://github.com/TrevTV) for laying the groundwork with his plugins. Additionally, thanks to [**IcySnex**](https://github.com/IcySnex) for providing the YouTube API. 🎉
---
## Contributing 🤝
If you'd like to contribute to Tubifarry, feel free to open issues or submit pull requests on the [GitHub repository](https://github.com/TypNull/Tubifarry). Your feedback and contributions are highly appreciated!
---
## License 📄
Tubifarry is licensed under the MIT License. See the [LICENSE](https://github.com/TypNull/Tubifarry/blob/master/LICENSE.txt) file for more details.
---
Enjoy seamless music downloads with Tubifarry! 🎧
================================================
FILE: Tubifarry/Blocklisting/BaseBlocklist.cs
================================================
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Download;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Parser.Model;
namespace Tubifarry.Blocklisting
{
public abstract class BaseBlocklist(IBlocklistRepository blocklistRepository) : IBlocklistForProtocol where TProtocol : IDownloadProtocol
{
private readonly IBlocklistRepository _blocklistRepository = blocklistRepository;
public string Protocol => typeof(TProtocol).Name;
public bool IsBlocklisted(int artistId, ReleaseInfo release) => _blocklistRepository.BlocklistedByTorrentInfoHash(artistId, release.Guid).Any(b => BaseBlocklist.SameRelease(b, release));
public Blocklist GetBlocklist(DownloadFailedEvent message) => new()
{
ArtistId = message.ArtistId,
AlbumIds = message.AlbumIds,
SourceTitle = message.SourceTitle,
Quality = message.Quality,
Date = DateTime.UtcNow,
PublishedDate = DateTime.TryParse(message.Data.GetValueOrDefault("publishedDate") ?? string.Empty, out DateTime publishedDate) ? publishedDate : null,
Size = long.Parse(message.Data.GetValueOrDefault("size", "0")),
Indexer = message.Data.GetValueOrDefault("indexer"),
Protocol = message.Data.GetValueOrDefault("protocol"),
Message = message.Message,
TorrentInfoHash = message.Data.GetValueOrDefault("guid")
};
private static bool SameRelease(Blocklist item, ReleaseInfo release) => release.Guid.IsNotNullOrWhiteSpace() ? release.Guid.Equals(item.TorrentInfoHash) : item.Indexer.Equals(release.Indexer, StringComparison.InvariantCultureIgnoreCase);
}
}
================================================
FILE: Tubifarry/Blocklisting/Blocklists.cs
================================================
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Indexers;
namespace Tubifarry.Blocklisting
{
public class YoutubeBlocklist(IBlocklistRepository blocklistRepository) : BaseBlocklist(blocklistRepository)
{ }
public class SoulseekBlocklist(IBlocklistRepository blocklistRepository) : BaseBlocklist(blocklistRepository)
{ }
public class QobuzBlocklist(IBlocklistRepository blocklistRepository) : BaseBlocklist(blocklistRepository)
{ }
public class LucidaBlocklist(IBlocklistRepository blocklistRepository) : BaseBlocklist(blocklistRepository)
{ }
public class SubSonicBlocklist(IBlocklistRepository blocklistRepository) : BaseBlocklist(blocklistRepository)
{ }
}
================================================
FILE: Tubifarry/Core/Model/AlbumData.cs
================================================
using NzbDrone.Core.Parser.Model;
using System.Text.RegularExpressions;
using Tubifarry.Core.Utilities;
namespace Tubifarry.Core.Model
{
///
/// Contains combined information about an album, search parameters, and search results.
///
public partial class AlbumData(string name, string downloadProtocol)
{
public string? Guid { get; set; }
public string IndexerName { get; } = name;
// Mixed
public string AlbumId { get; set; } = string.Empty;
// Properties from AlbumInfo
public string AlbumName { get; set; } = string.Empty;
public string ArtistName { get; set; } = string.Empty;
public string InfoUrl { get; set; } = string.Empty;
public string ReleaseDate { get; set; } = string.Empty;
public DateTime ReleaseDateTime { get; set; }
public string ReleaseDatePrecision { get; set; } = string.Empty;
public int TotalTracks { get; set; }
public bool ExplicitContent { get; set; }
public string CustomString { get; set; } = string.Empty;
public string CoverResolution { get; set; } = string.Empty;
// Properties from YoutubeSearchResults
public int Bitrate { get; set; }
public int BitDepth { get; set; }
public long Duration { get; set; }
// Soulseek
public long? Size { get; set; }
public int Priotity { get; set; }
public List? ExtraInfo { get; set; }
public string DownloadProtocol { get; set; } = downloadProtocol;
// Not used
public AudioFormat Codec { get; set; } = AudioFormat.AAC;
///
/// Converts AlbumData into a ReleaseInfo object.
///
public ReleaseInfo ToReleaseInfo() => new()
{
Guid = Guid ?? $"{IndexerName}-{AlbumId}-{Codec}-{Bitrate}-{BitDepth}",
Artist = ArtistName,
Album = AlbumName,
DownloadUrl = AlbumId,
InfoUrl = InfoUrl,
PublishDate = ReleaseDateTime == DateTime.MinValue ? DateTime.UtcNow : ReleaseDateTime,
DownloadProtocol = DownloadProtocol,
Title = ConstructTitle(),
Codec = Codec.ToString(),
Resolution = CoverResolution,
Source = CustomString,
Container = Bitrate.ToString(),
Size = Size ?? (Duration > 0 ? Duration : TotalTracks * 300) * Bitrate * 1000 / 8
};
///
/// Parses the release date based on the precision.
///
public void ParseReleaseDate() => ReleaseDateTime = ReleaseDatePrecision switch
{
"year" => new DateTime(int.Parse(ReleaseDate), 1, 1),
"month" => DateTime.ParseExact(ReleaseDate, "yyyy-MM", System.Globalization.CultureInfo.InvariantCulture),
"day" => DateTime.ParseExact(ReleaseDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture),
_ => throw new FormatException($"Unsupported release_date_precision: {ReleaseDatePrecision}"),
};
///
/// Constructs a title string for the album in a format optimized for parsing.
///
/// A formatted title string.
private string ConstructTitle()
{
string normalizedAlbumName = NormalizeAlbumName(AlbumName);
string title = $"{ArtistName} - {normalizedAlbumName}";
if (ReleaseDateTime != DateTime.MinValue)
title += $" ({ReleaseDateTime.Year})";
if (ExplicitContent)
title += " [Explicit]";
int calculatedBitrate = Bitrate;
if (calculatedBitrate <= 0 && Size.HasValue && Duration > 0)
calculatedBitrate = (int)(Size.Value * 8 / (Duration * 1000));
if (AudioFormatHelper.IsLossyFormat(Codec) && calculatedBitrate != 0)
title += $" [{Codec} {calculatedBitrate}kbps]";
else if (!AudioFormatHelper.IsLossyFormat(Codec) && BitDepth != 0)
title += $" [{Codec} {BitDepth}bit]";
else
title += $" [{Codec}]";
if (ExtraInfo?.Count > 0)
title += string.Concat(ExtraInfo.Where(info => !string.IsNullOrEmpty(info)).Select(info => $" [{info}]"));
title += " [WEB]";
return title;
}
///
/// Normalizes the album name to handle featuring artists and other parentheses.
///
/// The album name to normalize.
/// The normalized album name.
private static string NormalizeAlbumName(string albumName)
{
if (FeatRegex().IsMatch(albumName)) // TODO ISMatch vs Match
{
Match match = FeatRegex().Match(albumName);
string featuringArtist = albumName[(match.Index + match.Length)..].Trim();
albumName = $"{albumName[..match.Index].Trim()} (feat. {featuringArtist})";
}
return FeatReplaceRegex().Replace(albumName, match => $"{{{match.Value.Trim('(', ')')}}}");
}
[GeneratedRegex(@"(?i)\b(feat\.|ft\.|featuring)\b", RegexOptions.IgnoreCase, "de-DE")]
private static partial Regex FeatRegex();
[GeneratedRegex(@"\((?!feat\.)[^)]*\)")]
private static partial Regex FeatReplaceRegex();
}
}
================================================
FILE: Tubifarry/Core/Model/ApiCircuitBreaker.cs
================================================
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace Tubifarry.Core.Model
{
public interface ICircuitBreaker
{
bool IsOpen { get; }
void RecordSuccess();
void RecordFailure();
void Reset();
}
public class ApiCircuitBreaker(int failureThreshold = 5, int resetTimeoutMinutes = 5) : ICircuitBreaker
{
private int _failureCount;
private DateTime _lastFailure = DateTime.MinValue;
private readonly int _failureThreshold = failureThreshold;
private readonly TimeSpan _resetTimeout = TimeSpan.FromMinutes(resetTimeoutMinutes);
private readonly object _lock = new();
public bool IsOpen
{
get
{
lock (_lock)
{
if (_failureCount >= _failureThreshold)
{
if (DateTime.UtcNow - _lastFailure > _resetTimeout)
{
Reset();
return false;
}
return true;
}
return false;
}
}
}
public void RecordSuccess()
{
lock (_lock) { _failureCount = Math.Max(0, _failureCount - 1); }
}
public void RecordFailure()
{
lock (_lock)
{
_failureCount++;
_lastFailure = DateTime.UtcNow;
}
}
public void Reset()
{
lock (_lock) { _failureCount = 0; }
}
}
public static class CircuitBreakerFactory
{
private static readonly ConditionalWeakTable _typeBreakers = [];
private static readonly ConcurrentDictionary> _namedBreakers = new();
private static readonly object _cleanupLock = new();
private static DateTime _lastCleanup = DateTime.UtcNow;
private static readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(15);
///
/// Gets a circuit breaker for a specific type.
///
public static ICircuitBreaker GetBreaker() => GetBreaker(typeof(T));
///
/// Gets a circuit breaker for a specific object.
///
public static ICircuitBreaker GetBreaker(object obj) => GetBreaker(obj.GetType());
///
/// Gets a circuit breaker for a specific type.
///
public static ICircuitBreaker GetBreaker(Type type)
{
if (!_typeBreakers.TryGetValue(type, out ICircuitBreaker? breaker))
{
breaker = new ApiCircuitBreaker();
_typeBreakers.Add(type, breaker);
}
return breaker;
}
///
/// Gets a circuit breaker by name.
///
public static ICircuitBreaker GetBreaker(string name)
{
CleanupIfNeeded();
if (_namedBreakers.TryGetValue(name, out WeakReference? weakRef) && weakRef.TryGetTarget(out ICircuitBreaker? breaker))
return breaker;
breaker = new ApiCircuitBreaker();
_namedBreakers[name] = new WeakReference(breaker);
return breaker;
}
///
/// Gets a circuit breaker with custom configuration.
///
public static ICircuitBreaker GetCustomBreaker(int failureThreshold, int resetTimeoutMinutes)
{
Type type = typeof(T);
if (!_typeBreakers.TryGetValue(type, out ICircuitBreaker? breaker))
{
breaker = new ApiCircuitBreaker(failureThreshold, resetTimeoutMinutes);
_typeBreakers.Add(type, breaker);
}
return breaker;
}
private static void CleanupIfNeeded()
{
lock (_cleanupLock)
{
if (DateTime.UtcNow - _lastCleanup > _cleanupInterval)
{
CleanupNamedBreakers();
_lastCleanup = DateTime.UtcNow;
}
}
}
private static void CleanupNamedBreakers()
{
foreach (KeyValuePair> kvp in _namedBreakers)
{
if (!kvp.Value.TryGetTarget(out _))
_namedBreakers.TryRemove(kvp.Key, out _);
}
}
}
}
================================================
FILE: Tubifarry/Core/Model/AudioMetadataHandler.cs
================================================
using NLog;
using NzbDrone.Common.Instrumentation;
using NzbDrone.Core.Music;
using Tubifarry.Core.Records;
using Tubifarry.Core.Utilities;
using Xabe.FFmpeg;
using Xabe.FFmpeg.Downloader;
namespace Tubifarry.Core.Model
{
internal class AudioMetadataHandler
{
private readonly Logger? _logger;
private static bool? _isFFmpegInstalled = null;
public string TrackPath { get; private set; }
public Lyric? Lyric { get; set; }
public byte[]? AlbumCover { get; set; }
public bool UseID3v2_3 { get; set; }
public AudioMetadataHandler(string originalPath)
{
TrackPath = originalPath;
_logger = NzbDroneLogger.GetLogger(this);
}
///
/// Base codec parameters that don't change with bitrate settings
///
private static readonly Dictionary BaseConversionParameters = new()
{
{ AudioFormat.AAC, new[] { "-codec:a aac", "-movflags +faststart", "-aac_coder twoloop" } },
{ AudioFormat.MP3, new[] { "-codec:a libmp3lame" } },
{ AudioFormat.Opus, new[] { "-codec:a libopus", "-vbr on", "-application audio", "-vn" } },
{ AudioFormat.Vorbis, new[] { "-codec:a libvorbis" } },
{ AudioFormat.FLAC, new[] { "-codec:a flac", "-compression_level 8" } },
{ AudioFormat.ALAC, new[] { "-codec:a alac" } },
{ AudioFormat.WAV, new[] { "-codec:a pcm_s16le", "-ar 44100" } },
{ AudioFormat.MP4, new[] { "-codec:a aac", "-movflags +faststart", "-aac_coder twoloop" } },
{ AudioFormat.AIFF, new[] { "-codec:a pcm_s16be" } },
{ AudioFormat.OGG, new[] { "-codec:a libvorbis" } },
{ AudioFormat.AMR, new[] { "-codec:a libopencore_amrnb", "-ar 8000" } },
{ AudioFormat.WMA, new[] { "-codec:a wmav2" } }
};
///
/// Format-specific bitrate/quality parameter templates
///
private static readonly Dictionary> QualityParameters = new()
{
{
AudioFormat.AAC,
bitrate => bitrate < 256
? [$"-b:a {bitrate}k"]
: ["-q:a 2"] // 2 is highest quality for AAC
},
{
AudioFormat.MP3,
bitrate => {
int qualityLevel = bitrate switch {
>= 220 => 0, // V0 (~220-260kbps avg)
>= 190 => 1, // V1 (~190-250kbps)
>= 170 => 2, // V2 (~170-210kbps)
>= 150 => 3, // V3 (~150-195kbps)
>= 130 => 4, // V4 (~130-175kbps)
>= 115 => 5, // V5 (~115-155kbps)
>= 100 => 6, // V6 (~100-140kbps)
>= 85 => 7, // V7 (~85-125kbps)
>= 65 => 8, // V8 (~65-105kbps)
_ => 9 // V9 (~45-85kbps)
};
return [$"-q:a {qualityLevel}"];
}
},
{
AudioFormat.Opus,
bitrate => [$"-b:a {bitrate}k", "-compression_level 10"]
},
{
AudioFormat.Vorbis,
bitrate => [$"-q:a {AudioFormatHelper.MapBitrateToVorbisQuality(bitrate)}"]
},
{ AudioFormat.MP4, bitrate => [$"-b:a {bitrate}k"] },
{
AudioFormat.OGG,
bitrate => [$"-q:a {AudioFormatHelper.MapBitrateToVorbisQuality(bitrate)}"]
},
{ AudioFormat.AMR, bitrate => [$"-ab {bitrate}k"]},
{ AudioFormat.WMA, bitrate => [$"-b:a {bitrate}k"]}
};
private static readonly Dictionary> CBRQualityParameters = new()
{
{
AudioFormat.MP3,
bitrate => ["-b:a", $"{bitrate}k"]
},
{
AudioFormat.AAC,
bitrate => ["-b:a", $"{bitrate}k"]
},
{
AudioFormat.Opus,
bitrate => ["-b:a", $"{bitrate}k", "-vbr", "off"]
},
{
AudioFormat.MP4,
bitrate => ["-b:a", $"{bitrate}k"]
},
{
AudioFormat.AMR,
bitrate => ["-ab", $"{bitrate}k"]
},
{
AudioFormat.WMA,
bitrate => ["-b:a", $"{bitrate}k"]
}
};
private static readonly Dictionary> BitDepthParameters = new()
{
{
AudioFormat.FLAC,
bitDepth => bitDepth switch
{
16 => ["-sample_fmt", "s16"],
24 => ["-sample_fmt", "s32", "-bits_per_raw_sample", "24"],
32 => ["-sample_fmt", "s32"],
_ => []
}
},
{
AudioFormat.WAV,
bitDepth => bitDepth switch
{
16 => ["-codec:a", "pcm_s16le"],
24 => ["-codec:a", "pcm_s24le"],
32 => ["-codec:a", "pcm_s32le"],
_ => []
}
},
{
AudioFormat.AIFF,
bitDepth => bitDepth switch
{
16 => ["-codec:a", "pcm_s16be"],
24 => ["-codec:a", "pcm_s24be"],
32 => ["-codec:a", "pcm_s32be"],
_ => []
}
}
};
private static readonly string[] ExtractionParameters =
[
"-codec:a copy",
"-vn",
"-movflags +faststart"
];
private static readonly string[] VideoFormats =
[
"matroska", "webm",
"mov", "mp4", "m4a",
"avi",
"asf", "wmv", "wma",
"flv", "f4v",
"3gp", "3g2",
"mxf",
"ts", "m2ts"
];
private static readonly HashSet CoverArtCodecs = new(StringComparer.OrdinalIgnoreCase)
{
"mjpeg", "png", "bmp", "gif", "webp", "jpeg", "jpg", "tiff", "tif"
};
private async Task TryExtractCoverArtAsync()
{
try
{
using TagLib.File file = TagLib.File.Create(TrackPath);
byte[]? data = file.Tag.Pictures?.FirstOrDefault()?.Data?.Data;
if (data?.Length > 0)
return data;
}
catch { }
try
{
IMediaInfo mediaInfo = await FFmpeg.GetMediaInfo(TrackPath);
IVideoStream? coverStream = mediaInfo.VideoStreams
.FirstOrDefault(vs => CoverArtCodecs.Contains(vs.Codec ?? ""));
if (coverStream == null)
return null;
string tempCoverPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.jpg");
try
{
IConversion conversion = FFmpeg.Conversions.New()
.AddParameter($"-i \"{TrackPath}\"")
.AddParameter("-an -vcodec copy")
.SetOutput(tempCoverPath);
await conversion.Start();
if (File.Exists(tempCoverPath))
return await File.ReadAllBytesAsync(tempCoverPath);
}
finally
{
if (File.Exists(tempCoverPath))
File.Delete(tempCoverPath);
}
}
catch { }
return null;
}
///
/// Converts audio to the specified format with optional bitrate control.
///
/// Target audio format
/// Optional target bitrate in kbps
/// True if conversion succeeded, false otherwise
public async Task TryConvertToFormatAsync(AudioFormat audioFormat, int? targetBitrate = null, int? targetBitDepth = null, bool useCBR = false)
{
_logger?.Trace($"Converting {Path.GetFileName(TrackPath)} to {audioFormat}" +
(targetBitrate.HasValue ? $" at {targetBitrate}kbps" :
targetBitDepth.HasValue ? $" at {targetBitDepth}-bit" : ""));
if (!CheckFFmpegInstalled())
return false;
if (!await TryExtractAudioFromVideoAsync())
return false;
_logger?.Trace($"Looking up audio format: {audioFormat}");
if (audioFormat == AudioFormat.Unknown)
return true;
if (!BaseConversionParameters.ContainsKey(audioFormat))
return false;
string finalOutputPath = Path.ChangeExtension(TrackPath, AudioFormatHelper.GetFileExtensionForFormat(audioFormat));
string tempOutputPath = Path.ChangeExtension(TrackPath, $".converted{AudioFormatHelper.GetFileExtensionForFormat(audioFormat)}");
try
{
if (File.Exists(tempOutputPath))
File.Delete(tempOutputPath);
byte[]? preservedCoverArt = AlbumCover?.Length > 0 ? AlbumCover : await TryExtractCoverArtAsync();
IConversion conversion = await FFmpeg.Conversions.FromSnippet.Convert(TrackPath, tempOutputPath);
foreach (string parameter in BaseConversionParameters[audioFormat])
conversion.AddParameter(parameter);
if (AudioFormatHelper.IsLossyFormat(audioFormat))
{
int bitrate = targetBitrate ?? AudioFormatHelper.GetDefaultBitrate(audioFormat);
bitrate = AudioFormatHelper.ClampBitrate(audioFormat, bitrate);
string[] qualityParams;
string mode;
if (useCBR && CBRQualityParameters.ContainsKey(audioFormat))
{
qualityParams = CBRQualityParameters[audioFormat](bitrate);
mode = "CBR";
}
else if (QualityParameters.ContainsKey(audioFormat))
{
qualityParams = QualityParameters[audioFormat](bitrate);
mode = "VBR";
}
else
{
qualityParams = [$"-b:a {bitrate}k"];
mode = "fallback";
}
foreach (string param in qualityParams)
conversion.AddParameter(param);
_logger?.Trace($"Applied {mode} quality parameters for {audioFormat} at {bitrate}kbps: {string.Join(", ", qualityParams)}");
}
if (!AudioFormatHelper.IsLossyFormat(audioFormat) &&
BitDepthParameters.ContainsKey(audioFormat) &&
targetBitDepth.HasValue)
{
string[] bitDepthParams = BitDepthParameters[audioFormat](targetBitDepth.Value);
foreach (string param in bitDepthParams)
conversion.AddParameter(param);
_logger?.Trace($"Applied bit depth parameters for {audioFormat}: {targetBitDepth}-bit ({string.Join(", ", bitDepthParams)})");
}
_logger?.Trace($"Starting FFmpeg conversion");
await conversion.Start();
if (File.Exists(TrackPath))
File.Delete(TrackPath);
File.Move(tempOutputPath, finalOutputPath, true);
TrackPath = finalOutputPath;
if (preservedCoverArt?.Length > 0)
{
try
{
using TagLib.File destFile = TagLib.File.Create(TrackPath);
destFile.Tag.Pictures = [new TagLib.Picture(new TagLib.ByteVector(preservedCoverArt))
{
Type = TagLib.PictureType.FrontCover,
Description = "Album Cover"
}];
destFile.Save();
_logger?.Trace("Re-embedded cover art into converted file");
}
catch (Exception ex)
{
_logger?.Warn(ex, "Failed to re-embed cover art after conversion, cover art may be missing");
}
}
return true;
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to convert file to {audioFormat}: {TrackPath}");
return false;
}
}
public async Task IsVideoContainerAsync()
{
try
{
IMediaInfo mediaInfo = await FFmpeg.GetMediaInfo(TrackPath);
bool hasRealVideo = mediaInfo.VideoStreams.Any(vs =>
!CoverArtCodecs.Contains(vs.Codec ?? "") &&
!(vs.Duration.TotalSeconds < 1 && vs.Framerate <= 1));
if (hasRealVideo)
return true;
string probeResult = await Probe.New().Start($"-v error -show_entries format=format_name -of default=noprint_wrappers=1:nokey=1 \"{TrackPath}\"");
string formatName = probeResult?.Trim().ToLower() ?? "";
return VideoFormats.Any(container => formatName.Contains(container));
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to check file header: {TrackPath}");
return false;
}
}
public async Task TryExtractAudioFromVideoAsync()
{
if (!CheckFFmpegInstalled())
return false;
bool isVideo = await IsVideoContainerAsync();
if (!isVideo)
return await EnsureFileExtAsync();
_logger?.Trace($"Extracting audio from video file: {Path.GetFileName(TrackPath)}");
try
{
IMediaInfo mediaInfo = await FFmpeg.GetMediaInfo(TrackPath);
IAudioStream? audioStream = mediaInfo.AudioStreams.FirstOrDefault();
if (audioStream == null)
{
_logger?.Trace("No audio stream found in video file");
return false;
}
string codec = audioStream.Codec.ToLower();
string finalOutputPath = Path.ChangeExtension(TrackPath, AudioFormatHelper.GetFileExtensionForCodec(codec));
string tempOutputPath = Path.ChangeExtension(TrackPath, $".extracted{AudioFormatHelper.GetFileExtensionForCodec(codec)}");
if (File.Exists(tempOutputPath))
File.Delete(tempOutputPath);
IConversion conversion = await FFmpeg.Conversions.FromSnippet.ExtractAudio(TrackPath, tempOutputPath);
foreach (string parameter in ExtractionParameters)
conversion.AddParameter(parameter);
await conversion.Start();
if (File.Exists(TrackPath))
File.Delete(TrackPath);
File.Move(tempOutputPath, finalOutputPath, true);
TrackPath = finalOutputPath;
await EnsureFileExtAsync();
_logger?.Trace($"Successfully extracted audio to {Path.GetFileName(TrackPath)}");
return true;
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to extract audio from video: {TrackPath}");
return false;
}
}
///
/// Decrypts an encrypted audio file using FFmpeg with the provided decryption key.
///
/// The hex decryption key for the encrypted content.
/// The audio codec of the content (e.g., "flac", "opus", "eac3").
/// Cancellation token.
/// True if decryption was successful, false otherwise.
public async Task TryDecryptAsync(string decryptionKey, string? codec, CancellationToken token = default)
{
if (string.IsNullOrEmpty(decryptionKey))
return true;
if (!CheckFFmpegInstalled())
return false;
_logger?.Trace($"Decrypting file: {Path.GetFileName(TrackPath)}");
try
{
AudioFormat format = AudioFormatHelper.GetAudioFormatFromCodec(codec ?? "aac");
string extension = AudioFormatHelper.GetFileExtensionForFormat(format);
string outputPath = Path.ChangeExtension(TrackPath, extension);
string tempOutput = Path.ChangeExtension(TrackPath, $".dec{extension}");
if (File.Exists(tempOutput))
File.Delete(tempOutput);
IConversion conversion = FFmpeg.Conversions.New()
.AddParameter($"-decryption_key {decryptionKey}")
.AddParameter($"-i \"{TrackPath}\"")
.AddParameter("-c copy")
.SetOutput(tempOutput);
await conversion.Start(token);
if (File.Exists(TrackPath))
File.Delete(TrackPath);
File.Move(tempOutput, outputPath, true);
TrackPath = outputPath;
_logger?.Trace($"Successfully decrypted: {Path.GetFileName(TrackPath)}");
return true;
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to decrypt file: {TrackPath}");
return false;
}
}
public async Task TryCreateLrcFileAsync(CancellationToken token)
{
if (Lyric?.SyncedLyrics == null)
return false;
try
{
string lrcContent = string.Join(Environment.NewLine, Lyric.SyncedLyrics
.Where(lyric => !string.IsNullOrEmpty(lyric.LrcTimestamp) && !string.IsNullOrEmpty(lyric.Line))
.Select(lyric => $"{lyric.LrcTimestamp} {lyric.Line}"));
string lrcPath = Path.ChangeExtension(TrackPath, ".lrc");
await File.WriteAllTextAsync(lrcPath, lrcContent, token);
_logger?.Trace($"Created LRC file with {Lyric.SyncedLyrics.Count} synced lyrics");
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to create LRC file: {Path.ChangeExtension(TrackPath, ".lrc")}");
return false;
}
return true;
}
///
/// Ensures the file extension matches the actual audio codec.
///
/// True if the file extension is correct or was successfully corrected; otherwise, false.
public async Task EnsureFileExtAsync()
{
try
{
IMediaInfo mediaInfo = await FFmpeg.GetMediaInfo(TrackPath);
string codec = mediaInfo.AudioStreams.FirstOrDefault()?.Codec.ToLower() ?? string.Empty;
if (string.IsNullOrEmpty(codec))
return false;
string correctExtension = AudioFormatHelper.GetFileExtensionForCodec(codec);
string currentExtension = Path.GetExtension(TrackPath);
if (!string.Equals(currentExtension, correctExtension, StringComparison.OrdinalIgnoreCase))
{
string newPath = Path.ChangeExtension(TrackPath, correctExtension);
_logger?.Trace($"Correcting file extension from {currentExtension} to {correctExtension} for codec {codec}");
File.Move(TrackPath, newPath);
TrackPath = newPath;
}
return true;
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to ensure correct file extension: {TrackPath}");
return false;
}
}
public bool TryEmbedMetadata(Album albumInfo, Track trackInfo)
{
_logger?.Trace($"Embedding metadata for track: {trackInfo?.Title}");
try
{
using TagLib.File file = TagLib.File.Create(TrackPath);
if (UseID3v2_3)
{
TagLib.Id3v2.Tag.DefaultVersion = 3;
TagLib.Id3v2.Tag.ForceDefaultVersion = true;
}
else
{
TagLib.Id3v2.Tag.DefaultVersion = 4;
TagLib.Id3v2.Tag.ForceDefaultVersion = false;
}
if (!string.IsNullOrEmpty(trackInfo?.Title))
file.Tag.Title = trackInfo.Title;
if (trackInfo?.AbsoluteTrackNumber > 0)
file.Tag.Track = (uint)trackInfo.AbsoluteTrackNumber;
if (!string.IsNullOrEmpty(albumInfo?.Title))
file.Tag.Album = albumInfo.Title;
if (albumInfo?.ReleaseDate?.Year > 0)
file.Tag.Year = (uint)albumInfo.ReleaseDate.Value.Year;
if (albumInfo?.AlbumReleases?.Value?.FirstOrDefault()?.TrackCount > 0)
file.Tag.TrackCount = (uint)albumInfo.AlbumReleases.Value[0].TrackCount;
if (trackInfo?.MediumNumber > 0)
file.Tag.Disc = (uint)trackInfo.MediumNumber;
string? albumArtistName = albumInfo?.Artist?.Value?.Name;
string? trackArtistName = trackInfo?.Artist?.Value?.Name;
if (!string.IsNullOrEmpty(albumArtistName))
file.Tag.AlbumArtists = new[] { albumArtistName };
if (!string.IsNullOrEmpty(trackArtistName))
file.Tag.Performers = new[] { trackArtistName };
if (albumInfo?.AlbumReleases?.Value?.FirstOrDefault()?.Label?.Any() == true)
file.Tag.Copyright = albumInfo.AlbumReleases.Value[0].Label.FirstOrDefault();
if (albumInfo?.Genres?.Any() == true)
{
string[] validGenres = albumInfo.Genres.Where(g => !string.IsNullOrEmpty(g)).ToArray();
if (validGenres.Length > 0)
file.Tag.Genres = validGenres;
}
if (trackInfo?.Explicit == true)
file.Tag.Comment = "EXPLICIT";
if (!string.IsNullOrEmpty(trackInfo?.ForeignRecordingId) &&
file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3v2Tag)
{
TagLib.Id3v2.UserTextInformationFrame mbFrame = TagLib.Id3v2.UserTextInformationFrame.Get(id3v2Tag, "MusicBrainz Recording Id", true);
mbFrame.Text = [trackInfo.ForeignRecordingId];
}
try
{
if (AlbumCover?.Length > 0)
{
TagLib.Picture picture = new(new TagLib.ByteVector(AlbumCover))
{
Type = TagLib.PictureType.FrontCover,
Description = "Album Cover"
};
file.Tag.Pictures = [picture];
}
}
catch (Exception ex)
{
_logger?.Error(ex, "Failed to embed album cover");
}
file.Save();
return true;
}
catch (TagLib.CorruptFileException ex)
{
_logger?.Error(ex, $"File is corrupted or has incorrect extension: {TrackPath}");
return false;
}
catch (TagLib.UnsupportedFormatException ex)
{
_logger?.Error(ex, $"File format does not support metadata embedding: {TrackPath}");
return false;
}
catch (Exception ex)
{
_logger?.Error(ex, $"Failed to embed metadata in track: {TrackPath}");
return false;
}
}
///
/// Checks if the specified audio format is supported for encoding by FFmpeg.
///
/// The audio format to check
/// True if the format can be used as a conversion target, false otherwise
public static bool IsTargetFormatSupportedForEncoding(AudioFormat format) => BaseConversionParameters.ContainsKey(format);
/////
///// Checks if a given audio format supports embedded metadata tags.
/////
///// The audio format to check
///// True if the format supports metadata tagging, false otherwise
public static bool SupportsMetadataEmbedding(AudioFormat format) => format switch
{
// Formats that DO NOT support metadata embedding
AudioFormat.AC3 or AudioFormat.EAC3 or AudioFormat.MIDI => false,
// Formats that DO support metadata embedding
AudioFormat.AAC or AudioFormat.MP3 or AudioFormat.Opus or AudioFormat.Vorbis or
AudioFormat.FLAC or AudioFormat.WAV or AudioFormat.MP4 or AudioFormat.AIFF or
AudioFormat.OGG or AudioFormat.WMA or AudioFormat.ALAC or AudioFormat.APE => true,
// Unknown formats - assume they might support it
_ => true
};
///
/// Gets the actual audio codec from a file using FFmpeg and returns the corresponding AudioFormat.
///
/// Path to the audio file
/// AudioFormat enum value or AudioFormat.Unknown if codec is not supported or detection fails
public static async Task GetSupportedCodecAsync(string filePath)
{
try
{
IMediaInfo mediaInfo = await FFmpeg.GetMediaInfo(filePath);
IAudioStream? audioStream = mediaInfo.AudioStreams.FirstOrDefault();
if (audioStream == null)
{
NzbDroneLogger.GetLogger(typeof(AudioMetadataHandler)).Debug("No audio stream found in file: {0}", filePath);
return AudioFormat.Unknown;
}
string codec = audioStream.Codec.ToLower();
AudioFormat format = AudioFormatHelper.GetAudioFormatFromCodec(codec);
NzbDroneLogger.GetLogger(typeof(AudioMetadataHandler)).Trace("Detected codec '{0}' as format '{1}' for file: {2}", codec, format, filePath);
return format;
}
catch (Exception ex)
{
NzbDroneLogger.GetLogger(typeof(AudioMetadataHandler)).Error(ex, "Failed to detect codec for file: {0}", filePath);
return AudioFormat.Unknown;
}
}
public static bool CheckFFmpegInstalled()
{
if (_isFFmpegInstalled.HasValue)
return _isFFmpegInstalled.Value;
bool isInstalled = false;
if (!string.IsNullOrEmpty(FFmpeg.ExecutablesPath) && Directory.Exists(FFmpeg.ExecutablesPath))
{
string[] ffmpegPatterns = ["ffmpeg", "ffmpeg.exe", "ffmpeg.bin"];
string[] files = Directory.GetFiles(FFmpeg.ExecutablesPath);
if (files.Any(file => ffmpegPatterns.Contains(Path.GetFileName(file), StringComparer.OrdinalIgnoreCase) && IsExecutable(file)))
{
isInstalled = true;
}
}
if (!isInstalled)
{
string? ffmpegEnv = Environment.GetEnvironmentVariable("FFMPEG");
if (!string.IsNullOrEmpty(ffmpegEnv))
{
string dir = File.Exists(ffmpegEnv) ? Path.GetDirectoryName(ffmpegEnv)! : ffmpegEnv;
if (Directory.Exists(dir))
{
string[] ffmpegPatterns = ["ffmpeg", "ffmpeg.exe", "ffmpeg.bin"];
if (Directory.GetFiles(dir).Any(file => ffmpegPatterns.Contains(Path.GetFileName(file), StringComparer.OrdinalIgnoreCase) && IsExecutable(file)))
{
FFmpeg.SetExecutablesPath(dir);
isInstalled = true;
}
}
}
}
if (!isInstalled)
{
foreach (string path in Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? [])
{
if (Directory.Exists(path))
{
string[] ffmpegPatterns = ["ffmpeg", "ffmpeg.exe", "ffmpeg.bin"];
string[] files = Directory.GetFiles(path);
if (files.Any(file => ffmpegPatterns.Contains(Path.GetFileName(file), StringComparer.OrdinalIgnoreCase) && IsExecutable(file)))
{
FFmpeg.SetExecutablesPath(path);
isInstalled = true;
break;
}
}
}
}
if (!isInstalled)
NzbDroneLogger.GetLogger(typeof(AudioMetadataHandler)).Trace("FFmpeg not found in configured path or system PATH");
_isFFmpegInstalled = isInstalled;
return isInstalled;
}
private static bool IsExecutable(string filePath)
{
try
{
using FileStream stream = File.OpenRead(filePath);
byte[] magicNumber = new byte[4];
stream.Read(magicNumber, 0, 4);
// Windows PE
if (magicNumber[0] == 0x4D && magicNumber[1] == 0x5A)
return true;
// Linux ELF
if (magicNumber[0] == 0x7F && magicNumber[1] == 0x45 &&
magicNumber[2] == 0x4C && magicNumber[3] == 0x46)
return true;
// macOS Mach-O (32-bit: 0xFEEDFACE, 64-bit: 0xFEEDFACF)
if (magicNumber[0] == 0xFE && magicNumber[1] == 0xED &&
magicNumber[2] == 0xFA &&
(magicNumber[3] == 0xCE || magicNumber[3] == 0xCF))
return true;
// Universal Binary (fat_header)
if (magicNumber[0] == 0xCA && magicNumber[1] == 0xFE &&
magicNumber[2] == 0xBA && magicNumber[3] == 0xBE)
return true;
}
catch { }
return false;
}
public static void ResetFFmpegInstallationCheck() => _isFFmpegInstalled = null;
public static Task InstallFFmpeg(string path)
{
NzbDroneLogger.GetLogger(typeof(AudioMetadataHandler)).Trace($"Installing FFmpeg to: {path}");
ResetFFmpegInstallationCheck();
FFmpeg.SetExecutablesPath(path);
return CheckFFmpegInstalled() ? Task.CompletedTask : FFmpegDownloader.GetLatestVersion(FFmpegVersion.Official, path);
}
}
}
================================================
FILE: Tubifarry/Core/Model/FileCache.cs
================================================
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Tubifarry.Core.Model
{
public class FileCache
{
private readonly string _cacheDirectory;
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
public FileCache(string cacheDirectory)
{
if (!Directory.Exists(cacheDirectory))
Directory.CreateDirectory(cacheDirectory);
_cacheDirectory = cacheDirectory;
CleanupOldCacheFiles();
}
///
/// Deletes any JSON files stored directly in the root cache directory,
/// as these are from the old file naming scheme.
///
private void CleanupOldCacheFiles()
{
try
{
foreach (string file in Directory.GetFiles(_cacheDirectory, "*.json", SearchOption.TopDirectoryOnly))
File.Delete(file);
}
catch
{ }
}
///
/// Retrieves cached data for the given key if available and not expired.
///
public async Task GetAsync(string cacheKey)
{
string cacheFilePath = GetCacheFilePath(cacheKey);
if (!File.Exists(cacheFilePath))
return default;
string json = await File.ReadAllTextAsync(cacheFilePath);
CachedData? cachedData = JsonSerializer.Deserialize>(json);
if (cachedData == null || DateTime.UtcNow - cachedData.CreatedAt > cachedData.ExpirationDuration)
{
try
{
File.Delete(cacheFilePath);
}
catch { }
return default;
}
return cachedData.Data;
}
///
/// Caches the provided data with the specified expiration duration.
///
public async Task SetAsync(string cacheKey, T data, TimeSpan expirationDuration)
{
string cacheFilePath = GetCacheFilePath(cacheKey);
string directory = Path.GetDirectoryName(cacheFilePath)!;
if (!Directory.Exists(directory))
Directory.CreateDirectory(directory);
CachedData cachedData = new()
{
Data = data,
CreatedAt = DateTime.UtcNow,
ExpirationDuration = expirationDuration
};
string json = JsonSerializer.Serialize(cachedData, _jsonOptions);
await File.WriteAllTextAsync(cacheFilePath, json);
}
///
/// Checks whether a valid cache file exists for the given key.
///
public bool IsCacheValid(string cacheKey, TimeSpan expirationDuration)
{
string cacheFilePath = GetCacheFilePath(cacheKey);
if (!File.Exists(cacheFilePath))
return false;
string json = File.ReadAllText(cacheFilePath);
CachedData