├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ └── feature_request.yml
└── workflows
│ ├── build-upload-publish-dev.yml
│ ├── build-upload-publish.yml
│ ├── build.yml
│ ├── publish.yml
│ ├── upload-repo.yml
│ └── upload.yml
├── .gitignore
├── Jellyfin.Plugin.ListenBrainz.sln
├── Jellyfin.Plugin.ListenBrainz.sln.DotSettings
├── LICENSE
├── README.md
├── build.yaml
├── code.ruleset
├── doc
├── configuration.md
└── how-it-works.md
├── global.json
├── res
└── listenbrainz
│ ├── ListenBrainz_logo.svg
│ └── README.md
├── src
├── Jellyfin.Plugin.ListenBrainz.Api
│ ├── BaseApiClient.cs
│ ├── Exceptions
│ │ └── ListenBrainzException.cs
│ ├── HttpClientWrapper.cs
│ ├── Interfaces
│ │ ├── IBaseApiClient.cs
│ │ ├── IHttpClient.cs
│ │ ├── IListenBrainzApiClient.cs
│ │ ├── IListenBrainzRequest.cs
│ │ └── IListenBrainzResponse.cs
│ ├── Jellyfin.Plugin.ListenBrainz.Api.csproj
│ ├── ListenBrainzApiClient.cs
│ ├── Models
│ │ ├── AdditionalInfo.cs
│ │ ├── Feedback.cs
│ │ ├── Listen.cs
│ │ ├── Requests
│ │ │ ├── GetUserFeedbackRequest.cs
│ │ │ ├── GetUserListensRequest.cs
│ │ │ ├── RecordingFeedbackRequest.cs
│ │ │ ├── SubmitListensRequest.cs
│ │ │ └── ValidateTokenRequest.cs
│ │ ├── Responses
│ │ │ ├── GetUserFeedbackResponse.cs
│ │ │ ├── GetUserListensResponse.cs
│ │ │ ├── RecordingFeedbackResponse.cs
│ │ │ ├── SubmitListensResponse.cs
│ │ │ └── ValidateTokenResponse.cs
│ │ ├── TrackMetadata.cs
│ │ └── UserListensPayload.cs
│ └── Resources
│ │ ├── Endpoints.cs
│ │ ├── FeedbackScore.cs
│ │ ├── General.cs
│ │ ├── Headers.cs
│ │ ├── Limits.cs
│ │ └── ListenType.cs
├── Jellyfin.Plugin.ListenBrainz.Common
│ ├── DateUtils.cs
│ ├── Exceptions
│ │ ├── FatalException.cs
│ │ ├── NoDataException.cs
│ │ └── RateLimitException.cs
│ ├── Extensions
│ │ ├── EnumerableExtensions.cs
│ │ ├── LoggerExtensions.cs
│ │ └── StringExtensions.cs
│ ├── Jellyfin.Plugin.ListenBrainz.Common.csproj
│ └── Utils.cs
├── Jellyfin.Plugin.ListenBrainz.Http
│ ├── Exceptions
│ │ ├── InvalidResponseException.cs
│ │ └── RetryException.cs
│ ├── HttpClient.cs
│ ├── Interfaces
│ │ └── ISleepService.cs
│ ├── Jellyfin.Plugin.ListenBrainz.Http.csproj
│ ├── Services
│ │ └── DefaultSleepService.cs
│ └── Utils.cs
├── Jellyfin.Plugin.ListenBrainz.MusicBrainzApi
│ ├── BaseClient.cs
│ ├── Interfaces
│ │ ├── IMusicBrainzApiClient.cs
│ │ ├── IMusicBrainzRequest.cs
│ │ └── IMusicBrainzResponse.cs
│ ├── Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.csproj
│ ├── Json
│ │ └── KebabCaseNamingPolicy.cs
│ ├── Models
│ │ ├── ArtistCredit.cs
│ │ ├── Recording.cs
│ │ ├── Requests
│ │ │ └── RecordingRequest.cs
│ │ └── Responses
│ │ │ └── RecordingResponse.cs
│ ├── MusicBrainzApiClient.cs
│ └── Resources
│ │ ├── Api.cs
│ │ └── Endpoints.cs
└── Jellyfin.Plugin.ListenBrainz
│ ├── Clients
│ ├── ListenBrainzClient.cs
│ ├── MusicBrainzClient.cs
│ └── Utils.cs
│ ├── Configuration
│ ├── LibraryConfig.cs
│ ├── PluginConfiguration.cs
│ ├── UserConfig.cs
│ ├── bootstrap-grid.min.css
│ ├── configurationPage.html
│ └── styles.css
│ ├── Controllers
│ ├── InternalController.cs
│ └── PluginController.cs
│ ├── Dtos
│ ├── ArtistCredit.cs
│ ├── AudioItemMetadata.cs
│ ├── JellyfinMediaLibrary.cs
│ ├── StoredListen.cs
│ ├── TrackedItem.cs
│ └── ValidatedToken.cs
│ ├── Exceptions
│ ├── MetadataException.cs
│ └── PluginException.cs
│ ├── Extensions
│ ├── AudioExtensions.cs
│ ├── BaseItemExtensions.cs
│ ├── LibraryManagerExtensions.cs
│ └── UserExtensions.cs
│ ├── Interfaces
│ ├── IBackupManager.cs
│ ├── ICacheManager.cs
│ ├── IFavoriteSyncService.cs
│ ├── IListenBrainzClient.cs
│ ├── IListensCache.cs
│ ├── IListensCacheManager.cs
│ ├── IMusicBrainzClient.cs
│ └── IPluginConfigService.cs
│ ├── Jellyfin.Plugin.ListenBrainz.csproj
│ ├── Managers
│ ├── BackupManager.cs
│ ├── ListensCacheManager.cs
│ └── PlaybackTrackingManager.cs
│ ├── Plugin.cs
│ ├── PluginImplementation.cs
│ ├── PluginService.cs
│ ├── Services
│ ├── DefaultFavoriteSyncService.cs
│ └── DefaultPluginConfigService.cs
│ └── Tasks
│ ├── LovedTracksSyncTask.cs
│ └── ResubmitListensTask.cs
└── tests
├── Jellyfin.Plugin.ListenBrainz.Api.Tests
├── BaseApiClientTests.cs
├── Jellyfin.Plugin.ListenBrainz.Api.Tests.csproj
├── JsonEncodeTests.cs
└── LimitsTests.cs
├── Jellyfin.Plugin.ListenBrainz.Common.Tests
├── EnumerableExtensionsTests.cs
├── GlobalUsings.cs
├── Jellyfin.Plugin.ListenBrainz.Common.Tests.csproj
└── StringExtensionsTests.cs
├── Jellyfin.Plugin.ListenBrainz.Http.Tests
├── ClientTests.cs
├── Jellyfin.Plugin.ListenBrainz.Http.Tests.csproj
└── UtilsTests.cs
├── Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Tests
└── Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Tests.csproj
└── Jellyfin.Plugin.ListenBrainz.Tests
├── AudioItemMetadataTests.cs
├── Extensions
└── LibraryManagerExtensionsTests.cs
├── Jellyfin.Plugin.ListenBrainz.Tests.csproj
├── ListensCacheTests.cs
├── MockPlugin.cs
├── Services
└── FavoriteSyncServiceTests.cs
└── Tasks
├── LovedTracksSyncTaskTests.cs
└── ResubmitListenTaskTests.cs
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: Report an issue in the plugin.
3 | title: "[Bug]: "
4 | labels: ["bug"]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Please take your time to fill out this bug report. Reports filled with obvious little to no effort are likely
10 | to be closed without further comments. Thank you.
11 | - type: textarea
12 | id: what-happened
13 | attributes:
14 | label: Bug description
15 | description: A clear and concise description of what is not working.
16 | placeholder: It worked before and now it's broken.
17 | validations:
18 | required: true
19 | - type: textarea
20 | id: steps-to-reproduce
21 | attributes:
22 | label: Steps to reproduce
23 | description: Steps to reproduce the bug.
24 | placeholder: |
25 | 1. Do something
26 | 2. Wait for '....'
27 | 3. See error
28 | validations:
29 | required: true
30 | - type: textarea
31 | id: expected-behavior
32 | attributes:
33 | label: Expected behavior
34 | description: What should happen if the feature worked correctly?
35 | - type: textarea
36 | id: actual-behavior
37 | attributes:
38 | label: Actual behavior
39 | description: What happens instead?
40 | - type: textarea
41 | id: logs
42 | attributes:
43 | label: Jellyfin logs
44 | description: |
45 | Paste any relevant log output here. In case of listen processing issues, ideally from the point of picking up
46 | the event by plugin, up until the error. Please make sure to enable debug logging as there will be much more
47 | information available which may speed up investigating and fixing the issue. Check out the Debug logging section
48 | in the plugin README to learn how to properly set up debug logging.
49 | render: shell
50 | - type: input
51 | id: plugin-version
52 | attributes:
53 | label: Plugin version
54 | description: What plugin version do you have currently installed?
55 | placeholder: 3.x.y.z
56 | validations:
57 | required: true
58 | - type: input
59 | id: jellyfin-version
60 | attributes:
61 | label: Jellyfin Version
62 | description: What Jellyfin version are you running?
63 | placeholder: 10.a.b
64 | validations:
65 | required: true
66 | - type: textarea
67 | id: additional-ctx
68 | attributes:
69 | label: Additional info
70 | description: Add any other info or screenshots you think would help to investigate and/or fix the issue.
71 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: I have a question
4 | url: https://github.com/lyarenei/jellyfin-plugin-listenbrainz/discussions/categories/q-a
5 | about: Have a question about the plugin? Ask here.
6 | - name: Other
7 | url: https://github.com/lyarenei/jellyfin-plugin-listenbrainz/discussions/categories/general
8 | about: Have something which does not fit in any category? Post it here.
9 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Suggest a new feature or an improvement for the plugin.
3 | title: "[Request]: "
4 | labels: ["enhancement"]
5 | body:
6 | - type: textarea
7 | id: description
8 | attributes:
9 | label: Description
10 | description: A clear and concise description of your suggestion.
11 | validations:
12 | required: true
13 | - type: textarea
14 | id: suggested-solution
15 | attributes:
16 | label: Proposed solution
17 | description: Describe possible solution(s).
18 | - type: textarea
19 | id: additional-ctx
20 | attributes:
21 | label: Additional context
22 | description: Add any other context or screenshots which would help to better illustrate or further clarify your idea.
23 |
--------------------------------------------------------------------------------
/.github/workflows/build-upload-publish-dev.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Build and release plugin (dev)'
3 |
4 | on:
5 | pull_request:
6 | branches:
7 | - main
8 | paths-ignore:
9 | - '**/*.md'
10 | - '**/*.yml'
11 | - '**/*.yaml'
12 | workflow_dispatch:
13 |
14 | jobs:
15 | test:
16 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master
17 | with:
18 | dotnet-version: ${{ vars.DOTNET_8 }}
19 |
20 | build:
21 | uses: ./.github/workflows/build.yml
22 |
23 | upload-repo:
24 | if: ${{ contains(github.repository, 'lyarenei/') }}
25 | needs: build
26 | uses: ./.github/workflows/upload-repo.yml
27 | with:
28 | repo: ${{ vars.REPO_DEV }}
29 | secrets:
30 | host: ${{ secrets.DEPLOY_HOST }}
31 | user: ${{ secrets.DEPLOY_USER }}
32 | key: ${{ secrets.DEPLOY_KEY }}
33 |
34 | update-manifest:
35 | if: ${{ contains(github.repository, 'lyarenei/') }}
36 | needs: upload-repo
37 | uses: ./.github/workflows/publish.yml
38 | with:
39 | repo: ${{ vars.REPO_DEV }}
40 | secrets:
41 | host: ${{ secrets.DEPLOY_HOST }}
42 | user: ${{ secrets.DEPLOY_USER }}
43 | key: ${{ secrets.DEPLOY_KEY }}
44 |
--------------------------------------------------------------------------------
/.github/workflows/build-upload-publish.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Build and release plugin'
3 |
4 | on:
5 | release:
6 | types:
7 | - released
8 | workflow_dispatch:
9 |
10 | jobs:
11 | test:
12 | uses: jellyfin/jellyfin-meta-plugins/.github/workflows/test.yaml@master
13 | with:
14 | dotnet-version: ${{ vars.DOTNET_8 }}
15 |
16 | build:
17 | needs: test
18 | uses: ./.github/workflows/build.yml
19 |
20 | upload:
21 | needs: build
22 | uses: ./.github/workflows/upload.yml
23 |
24 | upload-repo:
25 | if: ${{ contains(github.repository, 'lyarenei/') }}
26 | needs: build
27 | uses: ./.github/workflows/upload-repo.yml
28 | with:
29 | repo: ${{ vars.REPO_DEFAULT }}
30 | secrets:
31 | host: ${{ secrets.DEPLOY_HOST }}
32 | user: ${{ secrets.DEPLOY_USER }}
33 | key: ${{ secrets.DEPLOY_KEY }}
34 |
35 | update-manifest:
36 | if: ${{ contains(github.repository, 'lyarenei/') }}
37 | needs: upload-repo
38 | uses: ./.github/workflows/publish.yml
39 | with:
40 | repo: ${{ vars.REPO_DEFAULT }}
41 | secrets:
42 | host: ${{ secrets.DEPLOY_HOST }}
43 | user: ${{ secrets.DEPLOY_USER }}
44 | key: ${{ secrets.DEPLOY_KEY }}
45 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Build Plugin'
3 |
4 | on:
5 | workflow_call:
6 | inputs:
7 | dotnet-version:
8 | type: string
9 | required: false
10 | default: "8.0"
11 | dotnet-target:
12 | type: string
13 | required: false
14 | default: "net8.0"
15 |
16 | jobs:
17 | build:
18 | runs-on: ubuntu-latest
19 | steps:
20 | - name: Checkout Repository
21 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
22 |
23 | - name: Setup .NET
24 | uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
25 | with:
26 | dotnet-version: "${{ inputs.dotnet-version }}"
27 |
28 | - name: Build Jellyfin Plugin
29 | uses: oddstr13/jellyfin-plugin-repository-manager@9497a0a499416cc572ed2e07a391d9f943a37b4d # v1.1.1
30 | id: jprm
31 | with:
32 | dotnet-target: "${{ inputs.dotnet-target }}"
33 |
34 | - name: Upload Artifact
35 | uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
36 | with:
37 | name: build-artifact
38 | retention-days: 14
39 | if-no-files-found: error
40 | path: ${{ steps.jprm.outputs.artifact }}
41 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Publish Plugin'
3 |
4 | on:
5 | workflow_call:
6 | inputs:
7 | repo:
8 | type: string
9 | required: true
10 | secrets:
11 | host:
12 | required: true
13 | user:
14 | required: true
15 | key:
16 | required: true
17 |
18 | jobs:
19 | publish:
20 | runs-on: ubuntu-latest
21 | env:
22 | MANIFEST_FILE: "/var/www/repos/${{ inputs.repo }}/manifest.json"
23 | REPO_PATH: "/var/www/repos/${{ inputs.repo }}"
24 | REPO_URL: "https://repo.xkrivo.net/${{ inputs.repo }}"
25 | steps:
26 | - name: Update Plugin Manifest
27 | uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
28 | with:
29 | host: ${{ secrets.host }}
30 | username: ${{ secrets.user }}
31 | key: ${{ secrets.key }}
32 | script_stop: true
33 | envs: MANIFEST_FILE,REPO_PATH,REPO_URL
34 | script: |-
35 | lockfile="/run/lock/jprm.lock"
36 | pushd "${REPO_PATH}/${{ github.repository }}/${{ inputs.version }}" || exit 1
37 | (
38 | flock -x 300
39 | python3.9 -m jprm --verbosity=debug repo add --url="${REPO_URL}" "${MANIFEST_FILE}" ./*.zip || exit 1
40 | ) 300>${lockfile}
41 | popd || exit 1
42 | rm -r "${REPO_PATH}/${{ github.repository }}/${{ inputs.version }}" || exit 1
43 |
--------------------------------------------------------------------------------
/.github/workflows/upload-repo.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Upload plugin to repository'
3 |
4 | on:
5 | workflow_call:
6 | inputs:
7 | repo:
8 | type: string
9 | required: true
10 | secrets:
11 | host:
12 | required: true
13 | user:
14 | required: true
15 | key:
16 | required: true
17 |
18 | jobs:
19 | upload-repo:
20 | runs-on: ubuntu-latest
21 | steps:
22 | - name: Download Artifact
23 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
24 | with:
25 | name: build-artifact
26 |
27 | - name: Ensure Destination Path Exists
28 | uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
29 | with:
30 | host: ${{ secrets.host }}
31 | username: ${{ secrets.user }}
32 | key: ${{ secrets.key }}
33 | script_stop: true
34 | script: |-
35 | mkdir -p "/var/www/repos/${{ inputs.repo }}/${{ github.repository }}/${{ inputs.version }}" || exit 1
36 |
37 | - name: Upload Jellyfin Plugin Repository Assets
38 | uses: burnett01/rsync-deployments@796cf0d5e4b535745ce49d7429f77cf39e25ef39 # v7.0.1
39 | with:
40 | switches: -vrptz
41 | path: ./*.zip
42 | remote_path: /var/www/repos/${{ inputs.repo }}/${{ github.repository }}/${{ inputs.version }}
43 | remote_host: ${{ secrets.host }}
44 | remote_user: ${{ secrets.user }}
45 | remote_key: ${{ secrets.key }}
46 |
--------------------------------------------------------------------------------
/.github/workflows/upload.yml:
--------------------------------------------------------------------------------
1 | # $schema: https://json.schemastore.org/github-workflow
2 | name: 'Upload plugin to GH release'
3 |
4 | on:
5 | workflow_call:
6 |
7 | jobs:
8 | upload:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Download Artifact
12 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
13 | with:
14 | name: build-artifact
15 |
16 | - name: Prepare GH Release Assets
17 | run: |-
18 | for file in ./*; do
19 | md5sum ${file#./} >> ${file%.*}.md5
20 | sha256sum ${file#./} >> ${file%.*}.sha256
21 | done
22 | ls -l
23 |
24 | - name: Upload GH Release Assets
25 | uses: shogo82148/actions-upload-release-asset@aac270e08f6b4547ada0b3800f88e1eb3ce9d400 # v1.7.7
26 | with:
27 | upload_url: ${{ github.event.release.upload_url }}
28 | asset_path: ./*
29 |
--------------------------------------------------------------------------------
/Jellyfin.Plugin.ListenBrainz.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}"
4 | EndProject
5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{99C564F4-BF8F-4555-AAB7-3A379C784CC4}"
6 | EndProject
7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Http", "src\Jellyfin.Plugin.ListenBrainz.Http\Jellyfin.Plugin.ListenBrainz.Http.csproj", "{33145D22-8C66-4C69-A409-3934840EAF4C}"
8 | EndProject
9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Http.Tests", "tests\Jellyfin.Plugin.ListenBrainz.Http.Tests\Jellyfin.Plugin.ListenBrainz.Http.Tests.csproj", "{B55D95AE-9B00-488D-B78B-86308C9129A7}"
10 | EndProject
11 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.MusicBrainzApi", "src\Jellyfin.Plugin.ListenBrainz.MusicBrainzApi\Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.csproj", "{27CBD9F1-5529-400D-8AD7-8BE32465D0E5}"
12 | EndProject
13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Tests", "tests\Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Tests\Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Tests.csproj", "{08A62589-F3AC-44B3-810C-0D69814B04AC}"
14 | EndProject
15 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Api", "src\Jellyfin.Plugin.ListenBrainz.Api\Jellyfin.Plugin.ListenBrainz.Api.csproj", "{BEB89977-4825-4591-9886-0920CE9D93EC}"
16 | EndProject
17 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Api.Tests", "tests\Jellyfin.Plugin.ListenBrainz.Api.Tests\Jellyfin.Plugin.ListenBrainz.Api.Tests.csproj", "{7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B}"
18 | EndProject
19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz", "src\Jellyfin.Plugin.ListenBrainz\Jellyfin.Plugin.ListenBrainz.csproj", "{5E3664B9-03C9-4357-86C5-B9B6E18C1098}"
20 | EndProject
21 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Tests", "tests\Jellyfin.Plugin.ListenBrainz.Tests\Jellyfin.Plugin.ListenBrainz.Tests.csproj", "{773CDB41-E866-439A-A6F1-79308AF97AB8}"
22 | EndProject
23 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Common", "src\Jellyfin.Plugin.ListenBrainz.Common\Jellyfin.Plugin.ListenBrainz.Common.csproj", "{9EB1DCAD-69EE-4AAF-813C-454FB8E87417}"
24 | EndProject
25 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.ListenBrainz.Common.Tests", "tests\Jellyfin.Plugin.ListenBrainz.Common.Tests\Jellyfin.Plugin.ListenBrainz.Common.Tests.csproj", "{DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1}"
26 | EndProject
27 | Global
28 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
29 | Debug|Any CPU = Debug|Any CPU
30 | Release|Any CPU = Release|Any CPU
31 | EndGlobalSection
32 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
33 | {33145D22-8C66-4C69-A409-3934840EAF4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
34 | {33145D22-8C66-4C69-A409-3934840EAF4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
35 | {33145D22-8C66-4C69-A409-3934840EAF4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
36 | {33145D22-8C66-4C69-A409-3934840EAF4C}.Release|Any CPU.Build.0 = Release|Any CPU
37 | {B55D95AE-9B00-488D-B78B-86308C9129A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
38 | {B55D95AE-9B00-488D-B78B-86308C9129A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
39 | {B55D95AE-9B00-488D-B78B-86308C9129A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
40 | {B55D95AE-9B00-488D-B78B-86308C9129A7}.Release|Any CPU.Build.0 = Release|Any CPU
41 | {27CBD9F1-5529-400D-8AD7-8BE32465D0E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
42 | {27CBD9F1-5529-400D-8AD7-8BE32465D0E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
43 | {27CBD9F1-5529-400D-8AD7-8BE32465D0E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
44 | {27CBD9F1-5529-400D-8AD7-8BE32465D0E5}.Release|Any CPU.Build.0 = Release|Any CPU
45 | {08A62589-F3AC-44B3-810C-0D69814B04AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
46 | {08A62589-F3AC-44B3-810C-0D69814B04AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
47 | {08A62589-F3AC-44B3-810C-0D69814B04AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
48 | {08A62589-F3AC-44B3-810C-0D69814B04AC}.Release|Any CPU.Build.0 = Release|Any CPU
49 | {BEB89977-4825-4591-9886-0920CE9D93EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
50 | {BEB89977-4825-4591-9886-0920CE9D93EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
51 | {BEB89977-4825-4591-9886-0920CE9D93EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
52 | {BEB89977-4825-4591-9886-0920CE9D93EC}.Release|Any CPU.Build.0 = Release|Any CPU
53 | {7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
54 | {7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B}.Debug|Any CPU.Build.0 = Debug|Any CPU
55 | {7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B}.Release|Any CPU.ActiveCfg = Release|Any CPU
56 | {7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B}.Release|Any CPU.Build.0 = Release|Any CPU
57 | {5E3664B9-03C9-4357-86C5-B9B6E18C1098}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
58 | {5E3664B9-03C9-4357-86C5-B9B6E18C1098}.Debug|Any CPU.Build.0 = Debug|Any CPU
59 | {5E3664B9-03C9-4357-86C5-B9B6E18C1098}.Release|Any CPU.ActiveCfg = Release|Any CPU
60 | {5E3664B9-03C9-4357-86C5-B9B6E18C1098}.Release|Any CPU.Build.0 = Release|Any CPU
61 | {773CDB41-E866-439A-A6F1-79308AF97AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
62 | {773CDB41-E866-439A-A6F1-79308AF97AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
63 | {773CDB41-E866-439A-A6F1-79308AF97AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
64 | {773CDB41-E866-439A-A6F1-79308AF97AB8}.Release|Any CPU.Build.0 = Release|Any CPU
65 | {9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
66 | {9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Debug|Any CPU.Build.0 = Debug|Any CPU
67 | {9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Release|Any CPU.ActiveCfg = Release|Any CPU
68 | {9EB1DCAD-69EE-4AAF-813C-454FB8E87417}.Release|Any CPU.Build.0 = Release|Any CPU
69 | {DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
70 | {DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
71 | {DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
72 | {DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1}.Release|Any CPU.Build.0 = Release|Any CPU
73 | EndGlobalSection
74 | GlobalSection(NestedProjects) = preSolution
75 | {33145D22-8C66-4C69-A409-3934840EAF4C} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
76 | {B55D95AE-9B00-488D-B78B-86308C9129A7} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
77 | {27CBD9F1-5529-400D-8AD7-8BE32465D0E5} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
78 | {08A62589-F3AC-44B3-810C-0D69814B04AC} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
79 | {BEB89977-4825-4591-9886-0920CE9D93EC} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
80 | {7C6FAEC4-5D44-4035-8590-2FCDFF1ABC2B} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
81 | {5E3664B9-03C9-4357-86C5-B9B6E18C1098} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
82 | {773CDB41-E866-439A-A6F1-79308AF97AB8} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
83 | {9EB1DCAD-69EE-4AAF-813C-454FB8E87417} = {BCFEC1CA-66E1-462A-BFA8-00D61DCCD970}
84 | {DD927BBD-0175-47BC-95DC-B3AA7B2CDBE1} = {99C564F4-BF8F-4555-AAB7-3A379C784CC4}
85 | EndGlobalSection
86 | EndGlobal
87 |
--------------------------------------------------------------------------------
/Jellyfin.Plugin.ListenBrainz.sln.DotSettings:
--------------------------------------------------------------------------------
1 |
2 | True
3 | True
4 | True
5 | True
6 | True
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Dominik Krivohlavek
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/build.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | name: "ListenBrainz"
3 | guid: "59B20823-AAFE-454C-A393-17427F518631"
4 | version: "5.2.0.4"
5 | targetAbi: "10.10.0.0"
6 | framework: "net8.0"
7 | overview: "Track your music habits with ListenBrainz."
8 | description: >
9 | A plugin to send your music listening activity on Jellyfin to ListenBrainz.
10 | category: "General"
11 | owner: "lyarenei"
12 | artifacts:
13 | - "Jellyfin.Plugin.ListenBrainz.dll"
14 | - "Jellyfin.Plugin.ListenBrainz.Api.dll"
15 | - "Jellyfin.Plugin.ListenBrainz.Common.dll"
16 | - "Jellyfin.Plugin.ListenBrainz.Http.dll"
17 | - "Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.dll"
18 | changelog: >
19 | Maintenance/fix
20 | - Reimplementation of loved sync task (#124 @lyarenei)
21 |
--------------------------------------------------------------------------------
/doc/how-it-works.md:
--------------------------------------------------------------------------------
1 | # How does the plugin work
2 |
3 | Here is a general description of how particular plugin features work. The plugin configuration is documented separately
4 | and can be found [here](configuration.md).
5 |
6 | ## Sending listens
7 |
8 | Sending listens is the main function of this plugin. In general, there are two types of a listen recognized by
9 | ListenBrainz (technically three, but the third one is just an extension). Each type is evaluated differently and the
10 | process is described below.
11 |
12 | ### Sending 'now playing' listen
13 |
14 | Sending `now playing` listen does not have any criteria and is completely optional. From the plugin perspective, the
15 | process begins by picking up a `PlaybackStart` event emitted by the server when informed of a playback start. After
16 | verifying that all required data are available for sending a listen, the plugin also checks if the user is configured
17 | and has enabled listen submission. Naturally, if everything is good, then the plugin fetches additional metadata from
18 | MusicBrainz (if enabled) and `now playing` listen is sent. As this listen type is not important that much, there is no
19 | error handling, besides some basic retrying handled automatically by the API client.
20 |
21 | ### Sending general listen
22 |
23 | The process for sending a listen begins pretty much the same as for `now playing` listen. There are 2 important
24 | differences though. The first one is that there is an additional requirement - the playback time of a track must be
25 | either at least 4 minutes or a half of its runtime. The second one is related to the event triggering this process.
26 | Depending on the configuration, the plugin will either react on a `PlaybackStopped` or `UserDataSaved` event emitted by
27 | the server. The first one is emitted when the server is informed about a playback stop. The second one is emitted when
28 | any kind of user data for that particular track is being saved. The plugin specifically watches for events with a reason
29 | for `PlaybackFinished`. These two modes are documented in more
30 | detail [here](configuration.md#use-alternative-event for-recognizing-listens). After checking all other conditions, the
31 | plugin will send a listen for the specified track. In case of a failure, the listen is automatically saved into a listen
32 | cache to retry later.
33 |
34 | ## Listen cache
35 |
36 | In case of listen submit failures, the listens are saved into a cache, so the data are not lost and the plugin can retry
37 | sending them in the future. The retry window is randomized on every server startup, with the window being no less than
38 | 24 hours and no more than 25 hours. If you wish to try resubmitting the listens right away, you can do so by triggering
39 | the scheduled task in the server admin interface. Favorites are not synced during this process.
40 |
41 | If a user does not have a valid configuration or has listen submitting disabled, no listens will be recorded in the
42 | cache for that user.
43 |
44 | ## Syncing favorites
45 |
46 | In addition to listen submission, this plugin also offers favorite sync. Or, more exactly, marking favorite tracks in
47 | Jellyfin as `loved` in ListenBrainz (and vice-versa). Synchronizing favorite artists and albums are not supported as
48 | this is not supported by ListenBrainz. Similarly, `hated` listens in ListenBrainz are not synced to Jellyfin as there
49 | is no such concept in Jellyfin.
50 |
51 | #### From Jellyfin to ListenBrainz
52 |
53 | Syncing always takes place right away after successfully submitting a listen. Please note it may take some time for the
54 | hearts to be updated in the ListenBrainz UI. Primarily, a recording MBID is used for the sync process, but if it's not
55 | available, the process falls back to using MSID.
56 |
57 | In the MSID case, you may see additional requests made for API token verification. This is to get a ListenBrainz
58 | username associated with the API token (the plugin did not store the username in earlier 3.x versions). If you wish to
59 | avoid this, go to plugin settings and save the user configuration, no changes are necessary. Upon saving, the plugin
60 | will try getting the username and save it in the configuration.
61 |
62 | When using MSID for the sync, the plugin tries to find the correct MSID at exponential intervals, up to 4 attempts
63 | (around 10 minutes). If the MSID is still not found, then the sync is cancelled.
64 |
65 | #### From ListenBrainz to Jellyfin
66 |
67 | Currently, only a manual task is available at this moment. This is because of an absence of recording MBIDs which make
68 | matching MBIDs to tracks a very expensive operation (in terms of time) and so it is impractical to run this sync regularly.
69 |
70 | You can run the sync task from the Jellyfin administration menu (under scheduled tasks). The task pulls loved listens
71 | for all users which have favorite synchronization enabled. Keep in mind, that the task can take a long time to complete.
72 | Hopefully this will change at some point in the future.
73 |
74 | For reference, a library of approximately 4000 tracks takes around 70 minutes to complete. This is then multiplied by
75 | number of users which have favorite syncing enabled (assuming all users have access to all tracks on the server).
76 |
--------------------------------------------------------------------------------
/global.json:
--------------------------------------------------------------------------------
1 | {
2 | "sdk": {
3 | "version": "8.0.0",
4 | "rollForward": "latestMajor",
5 | "allowPrerelease": false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/res/listenbrainz/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | This work, "ListenBrainz logo for Jellyfin plugin", is a derivative of "[ListenBrainz logo](https://github.com/metabrainz/metabrainz-logos/commit/10127d3e84e5bb7e1c8509f1da12223d19581e18)" by user [MonkeyDo](https://github.com/metabrainz/metabrainz-logos/commits?author=MonkeyDo) at [MetaBrainz Foundation](https://github.com/metabrainz). The source of this derivative work was used under CC BY-SA 4.0.
4 |
5 | Per the license requirement, this work (ListenBrainz logo for Jellyfin plugin) is also licensed under CC BY-SA 4.0 by [lyarenei](https://github.com/lyarenei).
6 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Exceptions/ListenBrainzException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Exceptions;
2 |
3 | ///
4 | /// Exception for various invalid ListenBrainz stuff.
5 | ///
6 | public class ListenBrainzException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public ListenBrainzException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public ListenBrainzException(string message) : base(message)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public ListenBrainzException(string message, Exception inner) : base(message, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/HttpClientWrapper.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api;
4 |
5 | ///
6 | public class HttpClientWrapper : IHttpClient
7 | {
8 | private readonly Http.HttpClient _client;
9 |
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | /// Underlying HTTP client.
14 | public HttpClientWrapper(Http.HttpClient client)
15 | {
16 | _client = client;
17 | }
18 |
19 | ///
20 | public Task SendRequest(HttpRequestMessage request, CancellationToken cancellationToken)
21 | {
22 | return _client.SendRequest(request, cancellationToken);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Interfaces/IBaseApiClient.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | ///
4 | /// Base ListenBrainz API client.
5 | ///
6 | public interface IBaseApiClient
7 | {
8 | ///
9 | /// Send a POST request to the ListenBrainz server.
10 | ///
11 | /// The request to send.
12 | /// Cancellation token.
13 | /// Data type of the request.
14 | /// Data type of the response.
15 | /// Request response.
16 | public Task SendPostRequest(TRequest request, CancellationToken cancellationToken)
17 | where TRequest : IListenBrainzRequest
18 | where TResponse : IListenBrainzResponse;
19 |
20 | ///
21 | /// Send a GET request to the ListenBrainz server.
22 | ///
23 | /// The request to send.
24 | /// Cancellation token.
25 | /// Data type of the request.
26 | /// Data type of the response.
27 | /// Request response.
28 | public Task SendGetRequest(TRequest request, CancellationToken cancellationToken)
29 | where TRequest : IListenBrainzRequest
30 | where TResponse : IListenBrainzResponse;
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Interfaces/IHttpClient.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | ///
4 | /// HttpClient used by the ListenBrainz base API client.
5 | ///
6 | public interface IHttpClient
7 | {
8 | ///
9 | /// Send a HTTP request.
10 | ///
11 | /// Request to send.
12 | /// Cancellation token.
13 | /// Task with result.
14 | public Task SendRequest(HttpRequestMessage request, CancellationToken cancellationToken);
15 | }
16 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Interfaces/IListenBrainzApiClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
5 |
6 | ///
7 | /// ListenBrainz API client.
8 | ///
9 | public interface IListenBrainzApiClient
10 | {
11 | ///
12 | /// Validate provided token.
13 | ///
14 | /// Validate token request.
15 | /// Cancellation token.
16 | /// Request response.
17 | public Task ValidateToken(ValidateTokenRequest request, CancellationToken cancellationToken);
18 |
19 | ///
20 | /// Submit listens.
21 | ///
22 | /// Submit listens request.
23 | /// Cancellation token.
24 | /// Request response.
25 | public Task SubmitListens(SubmitListensRequest request, CancellationToken cancellationToken);
26 |
27 | ///
28 | /// Submit a recording feedback.
29 | ///
30 | /// Recording feedback request.
31 | /// Cancellation token.
32 | /// Request response.
33 | public Task SubmitRecordingFeedback(RecordingFeedbackRequest request, CancellationToken cancellationToken);
34 |
35 | ///
36 | /// Get listens of a specified user.
37 | ///
38 | /// User listens request.
39 | /// Cancellation token.
40 | /// Request response.
41 | public Task GetUserListens(GetUserListensRequest request, CancellationToken cancellationToken);
42 |
43 | ///
44 | /// Get listen(s) feedback of a specified user.
45 | ///
46 | /// User feedback request.
47 | /// Cancellation token.
48 | /// Request response.
49 | public Task GetUserFeedback(GetUserFeedbackRequest request, CancellationToken cancellationToken);
50 | }
51 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Interfaces/IListenBrainzRequest.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
4 |
5 | ///
6 | /// ListenBrainz request.
7 | ///
8 | public interface IListenBrainzRequest
9 | {
10 | ///
11 | /// Gets API token for request authorization.
12 | ///
13 | [JsonIgnore]
14 | public string? ApiToken { get; init; }
15 |
16 | ///
17 | /// Gets API endpoint.
18 | ///
19 | [JsonIgnore]
20 | public string Endpoint { get; }
21 |
22 | ///
23 | /// Gets API base URL.
24 | ///
25 | [JsonIgnore]
26 | public string BaseUrl { get; init; }
27 |
28 | ///
29 | /// Gets request data as a dictionary.
30 | ///
31 | [JsonIgnore]
32 | public virtual Dictionary QueryDict => new();
33 | }
34 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Interfaces/IListenBrainzResponse.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
4 |
5 | ///
6 | /// ListenBrainz response.
7 | ///
8 | public interface IListenBrainzResponse
9 | {
10 | ///
11 | /// Gets or sets a value indicating whether response is OK.
12 | ///
13 | [JsonIgnore]
14 | public bool IsOk { get; set; }
15 |
16 | ///
17 | /// Gets a value indicating whether response is not OK.
18 | ///
19 | [JsonIgnore]
20 | public bool IsNotOk => !IsOk;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Jellyfin.Plugin.ListenBrainz.Api.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | true
8 | false
9 | AllEnabledByDefault
10 | ../../code.ruleset
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | all
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/ListenBrainzApiClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
3 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 | using Microsoft.Extensions.Logging;
5 |
6 | namespace Jellyfin.Plugin.ListenBrainz.Api;
7 |
8 | ///
9 | /// ListenBrainz API client.
10 | ///
11 | public class ListenBrainzApiClient : IListenBrainzApiClient
12 | {
13 | private readonly IBaseApiClient _apiClient;
14 | private readonly ILogger _logger;
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | /// Underlying base client.
20 | /// Logger instance.
21 | public ListenBrainzApiClient(IBaseApiClient apiClient, ILogger logger)
22 | {
23 | _apiClient = apiClient;
24 | _logger = logger;
25 | }
26 |
27 | ///
28 | public async Task ValidateToken(ValidateTokenRequest request, CancellationToken cancellationToken)
29 | {
30 | return await _apiClient.SendGetRequest(request, cancellationToken);
31 | }
32 |
33 | ///
34 | public async Task SubmitListens(SubmitListensRequest request, CancellationToken cancellationToken)
35 | {
36 | return await _apiClient.SendPostRequest(request, cancellationToken);
37 | }
38 |
39 | ///
40 | public async Task SubmitRecordingFeedback(RecordingFeedbackRequest request, CancellationToken cancellationToken)
41 | {
42 | return await _apiClient.SendPostRequest(request, cancellationToken);
43 | }
44 |
45 | ///
46 | public async Task GetUserListens(GetUserListensRequest request, CancellationToken cancellationToken)
47 | {
48 | return await _apiClient.SendGetRequest(request, cancellationToken);
49 | }
50 |
51 | ///
52 | public async Task GetUserFeedback(GetUserFeedbackRequest request, CancellationToken cancellationToken)
53 | {
54 | return await _apiClient.SendGetRequest(request, cancellationToken);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/AdditionalInfo.cs:
--------------------------------------------------------------------------------
1 | using Newtonsoft.Json;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models;
4 |
5 | ///
6 | /// Additional info.
7 | ///
8 | public class AdditionalInfo
9 | {
10 | ///
11 | /// Gets or sets artist MBIDs.
12 | ///
13 | public IEnumerable? ArtistMbids { get; set; }
14 |
15 | ///
16 | /// Gets or sets release group MBID.
17 | ///
18 | public string? ReleaseGroupMbid { get; set; }
19 |
20 | ///
21 | /// Gets or sets release MBID.
22 | ///
23 | public string? ReleaseMbid { get; set; }
24 |
25 | ///
26 | /// Gets or sets recording MBID.
27 | ///
28 | public string? RecordingMbid { get; set; }
29 |
30 | ///
31 | /// Gets or sets track MBID.
32 | ///
33 | public string? TrackMbid { get; set; }
34 |
35 | ///
36 | /// Gets or sets work MBIDs.
37 | ///
38 | public IEnumerable? WorkMbids { get; set; }
39 |
40 | ///
41 | /// Gets or sets track number in a release (album).
42 | /// Starts from 1.
43 | ///
44 | [JsonProperty("tracknumber")]
45 | public int? TrackNumber { get; set; }
46 |
47 | ///
48 | /// Gets or sets ISRC code.
49 | ///
50 | public string? Isrc { get; set; }
51 |
52 | ///
53 | /// Gets or sets Spotify URL associated with the recording.
54 | ///
55 | [JsonProperty("spotify_id")]
56 | public string? SpotifyUrl { get; set; }
57 |
58 | ///
59 | /// Gets or sets tags.
60 | ///
61 | public IEnumerable? Tags { get; set; }
62 |
63 | ///
64 | /// Gets or sets name of the media player.
65 | ///
66 | public string? MediaPlayer { get; set; }
67 |
68 | ///
69 | /// Gets or sets media player version.
70 | ///
71 | public string? MediaPlayerVersion { get; set; }
72 |
73 | ///
74 | /// Gets or sets name of the submission client.
75 | ///
76 | public string? SubmissionClient { get; set; }
77 |
78 | ///
79 | /// Gets or sets submission client version.
80 | ///
81 | public string? SubmissionClientVersion { get; set; }
82 |
83 | ///
84 | /// Gets or sets canonical domain of an online service.
85 | ///
86 | public string? MusicService { get; set; }
87 |
88 | ///
89 | /// Gets or sets name of the online service.
90 | ///
91 | public string? MusicServiceName { get; set; }
92 |
93 | ///
94 | /// Gets or sets url of the song/recording (if from online source).
95 | ///
96 | public string? OriginUrl { get; set; }
97 |
98 | ///
99 | /// Gets or sets duration in milliseconds.
100 | ///
101 | public long? DurationMs { get; set; }
102 | }
103 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Feedback.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models;
2 |
3 | ///
4 | /// ListenBrainz feedback model.
5 | ///
6 | public class Feedback
7 | {
8 | ///
9 | /// Gets or sets recording MBID.
10 | ///
11 | public string? RecordingMbid { get; set; }
12 | }
13 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Listen.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models;
2 |
3 | ///
4 | /// ListenBrainz Listen object.
5 | ///
6 | public class Listen
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public Listen()
12 | {
13 | TrackMetadata = new TrackMetadata();
14 | }
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | /// Full credit string for artists of the track.
20 | /// Track name.
21 | public Listen(string artistName, string trackName)
22 | {
23 | TrackMetadata = new TrackMetadata(artistName, trackName);
24 | }
25 |
26 | ///
27 | /// Gets or sets UNIX timestamp of the listen.
28 | ///
29 | public long? ListenedAt { get; set; }
30 |
31 | ///
32 | /// Gets or sets recording MSID.
33 | ///
34 | public string? RecordingMsid { get; set; }
35 |
36 | ///
37 | /// Gets or sets track metadata.
38 | ///
39 | public TrackMetadata TrackMetadata { get; set; }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Requests/GetUserFeedbackRequest.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.Text;
3 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
4 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
5 |
6 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
7 |
8 | ///
9 | /// User feedback request.
10 | ///
11 | public class GetUserFeedbackRequest : IListenBrainzRequest
12 | {
13 | private readonly string _userName;
14 | private readonly CompositeFormat _endpointFormat;
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | /// ListenBrainz username.
20 | /// Feedback type (score) to get. If unset, both loved and hated feedbacks are returned.
21 | /// Number of feedbacks to get.
22 | /// Feedback list offset.
23 | /// Include metadata.
24 | public GetUserFeedbackRequest(
25 | string userName,
26 | FeedbackScore? score = null,
27 | int count = Limits.DefaultItemsPerGet,
28 | int offset = 0,
29 | bool? metadata = null)
30 | {
31 | _endpointFormat = CompositeFormat.Parse(Endpoints.UserFeedback);
32 | _userName = userName;
33 | BaseUrl = General.BaseUrl;
34 | QueryDict = new Dictionary
35 | {
36 | { "count", count.ToString(NumberFormatInfo.InvariantInfo) },
37 | { "offset", offset.ToString(NumberFormatInfo.InvariantInfo) }
38 | };
39 |
40 | if (score is not null)
41 | {
42 | QueryDict.Add("score", score.Value.ToString(NumberFormatInfo.InvariantInfo));
43 | }
44 |
45 | if (metadata is not null)
46 | {
47 | QueryDict.Add("metadata", metadata.ToString()!.ToLowerInvariant());
48 | }
49 | }
50 |
51 | ///
52 | public string? ApiToken { get; init; }
53 |
54 | ///
55 | public string Endpoint => string.Format(CultureInfo.InvariantCulture, _endpointFormat, _userName);
56 |
57 | ///
58 | public string BaseUrl { get; init; }
59 |
60 | ///
61 | public Dictionary QueryDict { get; }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Requests/GetUserListensRequest.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.Text;
3 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
4 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
5 |
6 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
7 |
8 | ///
9 | /// User listens request.
10 | ///
11 | public class GetUserListensRequest : IListenBrainzRequest
12 | {
13 | private readonly string _userName;
14 | private readonly CompositeFormat _endpointFormat;
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | /// Name of the user's listens.
20 | /// Number of listens to fetch.
21 | public GetUserListensRequest(string userName, int listensNumber = 10)
22 | {
23 | _endpointFormat = CompositeFormat.Parse(Endpoints.ListensEndpoint);
24 | _userName = userName;
25 | BaseUrl = General.BaseUrl;
26 | QueryDict = new Dictionary { { "count", listensNumber.ToString(NumberFormatInfo.InvariantInfo) } };
27 | }
28 |
29 | ///
30 | public string? ApiToken { get; init; }
31 |
32 | ///
33 | public string Endpoint => string.Format(CultureInfo.InvariantCulture, _endpointFormat, _userName);
34 |
35 | ///
36 | public string BaseUrl { get; init; }
37 |
38 | ///
39 | public Dictionary QueryDict { get; }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Requests/RecordingFeedbackRequest.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
3 | using Newtonsoft.Json;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
6 |
7 | ///
8 | /// Recording feedback request.
9 | ///
10 | public class RecordingFeedbackRequest : IListenBrainzRequest
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | public RecordingFeedbackRequest()
16 | {
17 | BaseUrl = General.BaseUrl;
18 | Score = FeedbackScore.Neutral;
19 | }
20 |
21 | ///
22 | public string? ApiToken { get; init; }
23 |
24 | ///
25 | public string Endpoint => Endpoints.RecordingFeedback;
26 |
27 | ///
28 | public string BaseUrl { get; init; }
29 |
30 | ///
31 | /// Gets or sets MBID of the recording.
32 | ///
33 | public string? RecordingMbid { get; set; }
34 |
35 | ///
36 | /// Gets or sets MSID of the recording.
37 | ///
38 | public string? RecordingMsid { get; set; }
39 |
40 | ///
41 | /// Gets or sets feedback score.
42 | ///
43 | [JsonIgnore]
44 | public FeedbackScore Score { get; set; }
45 |
46 | ///
47 | /// Gets as an integer.
48 | ///
49 | [JsonProperty("score")]
50 | public int ScoreInt => Score.Value;
51 | }
52 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Requests/SubmitListensRequest.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
3 | using Newtonsoft.Json;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
6 |
7 | ///
8 | /// Submit listens request.
9 | ///
10 | public class SubmitListensRequest : IListenBrainzRequest
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | public SubmitListensRequest()
16 | {
17 | ListenType = ListenType.PlayingNow;
18 | BaseUrl = Resources.General.BaseUrl;
19 | Payload = new List();
20 | }
21 |
22 | ///
23 | public string? ApiToken { get; init; }
24 |
25 | ///
26 | public string Endpoint => Endpoints.SubmitListens;
27 |
28 | ///
29 | public string BaseUrl { get; init; }
30 |
31 | ///
32 | /// Gets listen type.
33 | ///
34 | [JsonIgnore]
35 | public ListenType ListenType { get; init; }
36 |
37 | ///
38 | /// Gets as a string.
39 | ///
40 | [JsonProperty("listen_type")]
41 | public string ListenTypeString => ListenType.Value;
42 |
43 | ///
44 | /// Gets or sets request payload.
45 | ///
46 | public IEnumerable Payload { get; set; }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Requests/ValidateTokenRequest.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
5 |
6 | ///
7 | /// Validate token request.
8 | ///
9 | public class ValidateTokenRequest : IListenBrainzRequest
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | /// API token to validate.
15 | public ValidateTokenRequest(string apiToken)
16 | {
17 | ApiToken = apiToken;
18 | BaseUrl = Resources.General.BaseUrl;
19 | QueryDict = new Dictionary();
20 | }
21 |
22 | ///
23 | public string? ApiToken { get; init; }
24 |
25 | ///
26 | public string Endpoint => Endpoints.ValidateToken;
27 |
28 | ///
29 | public string BaseUrl { get; init; }
30 |
31 | ///
32 | public Dictionary QueryDict { get; }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Responses/GetUserFeedbackResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 |
5 | ///
6 | /// User feedback response.
7 | ///
8 | public class GetUserFeedbackResponse : IListenBrainzResponse
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public GetUserFeedbackResponse()
14 | {
15 | Feedback = new List();
16 | }
17 |
18 | ///
19 | public bool IsOk { get; set; }
20 |
21 | ///
22 | public bool IsNotOk => !IsOk;
23 |
24 | ///
25 | /// Gets or sets count of feedbacks in this payload.
26 | ///
27 | public int Count { get; set; }
28 |
29 | ///
30 | /// Gets or sets results count offset.
31 | ///
32 | public int Offset { get; set; }
33 |
34 | ///
35 | /// Gets or sets the feedback total count.
36 | ///
37 | public int TotalCount { get; set; }
38 |
39 | ///
40 | /// Gets or sets user's listens.
41 | ///
42 | public IEnumerable Feedback { get; set; }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Responses/GetUserListensResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 |
5 | ///
6 | /// User listens response.
7 | ///
8 | public class GetUserListensResponse : IListenBrainzResponse
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public GetUserListensResponse()
14 | {
15 | Payload = new UserListensPayload();
16 | }
17 |
18 | ///
19 | public bool IsOk { get; set; }
20 |
21 | ///
22 | public bool IsNotOk => !IsOk;
23 |
24 | ///
25 | /// Gets or sets response payload.
26 | ///
27 | public UserListensPayload Payload { get; set; }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Responses/RecordingFeedbackResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 |
5 | ///
6 | /// Recording feedback response.
7 | ///
8 | public class RecordingFeedbackResponse : IListenBrainzResponse
9 | {
10 | ///
11 | public bool IsOk { get; set; }
12 |
13 | ///
14 | public bool IsNotOk => !IsOk;
15 | }
16 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Responses/SubmitListensResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 |
5 | ///
6 | /// Submit listens response.
7 | ///
8 | public class SubmitListensResponse : IListenBrainzResponse
9 | {
10 | ///
11 | public bool IsOk { get; set; }
12 |
13 | ///
14 | public bool IsNotOk => !IsOk;
15 | }
16 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/Responses/ValidateTokenResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
4 |
5 | ///
6 | /// Validate token response.
7 | ///
8 | public class ValidateTokenResponse : IListenBrainzResponse
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public ValidateTokenResponse()
14 | {
15 | Code = string.Empty;
16 | Message = string.Empty;
17 | Valid = false;
18 | }
19 |
20 | ///
21 | public bool IsOk { get; set; }
22 |
23 | ///
24 | public bool IsNotOk => !IsOk;
25 |
26 | ///
27 | /// Gets or sets status code.
28 | ///
29 | public string Code { get; set; }
30 |
31 | ///
32 | /// Gets or sets additional message.
33 | ///
34 | public string Message { get; set; }
35 |
36 | ///
37 | /// Gets or sets a value indicating whether token is valid.
38 | ///
39 | public bool Valid { get; set; }
40 |
41 | ///
42 | /// Gets or sets user name associated with the token.
43 | ///
44 | public string? UserName { get; set; }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/TrackMetadata.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models;
2 |
3 | ///
4 | /// ListenBrainz track metadata.
5 | ///
6 | public class TrackMetadata
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public TrackMetadata()
12 | {
13 | ArtistName = string.Empty;
14 | TrackName = string.Empty;
15 | }
16 |
17 | ///
18 | /// Initializes a new instance of the class.
19 | ///
20 | /// Name of the artist(s).
21 | /// Name of the track.
22 | public TrackMetadata(string artistName, string trackName)
23 | {
24 | ArtistName = artistName;
25 | TrackName = trackName;
26 | }
27 |
28 | ///
29 | /// Gets or sets artist name.
30 | ///
31 | public string ArtistName { get; set; }
32 |
33 | ///
34 | /// Gets or sets track name.
35 | ///
36 | public string TrackName { get; set; }
37 |
38 | ///
39 | /// Gets or sets release (album) name.
40 | ///
41 | public string? ReleaseName { get; set; }
42 |
43 | ///
44 | /// Gets or sets additional metadata.
45 | ///
46 | public AdditionalInfo? AdditionalInfo { get; set; }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Models/UserListensPayload.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Models;
2 |
3 | ///
4 | /// User listens response payload.
5 | ///
6 | public class UserListensPayload
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public UserListensPayload()
12 | {
13 | UserId = string.Empty;
14 | Listens = new List();
15 | }
16 |
17 | ///
18 | /// Gets or sets count of listens in this payload.
19 | ///
20 | public int Count { get; set; }
21 |
22 | ///
23 | /// Gets or sets listen's user MBID.
24 | ///
25 | public string UserId { get; set; }
26 |
27 | ///
28 | /// Gets or sets user's listens.
29 | ///
30 | public IEnumerable Listens { get; set; }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/Endpoints.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
2 |
3 | ///
4 | /// ListenBrainz API endpoints.
5 | ///
6 | public static class Endpoints
7 | {
8 | ///
9 | /// Endpoint for submitting listens.
10 | ///
11 | public const string SubmitListens = "submit-listens";
12 |
13 | ///
14 | /// Endpoint for token validation.
15 | ///
16 | public const string ValidateToken = "validate-token";
17 |
18 | ///
19 | /// Feedback endpoint base.
20 | ///
21 | private const string FeedbackEndpointBase = "feedback";
22 |
23 | ///
24 | /// Endpoint for recording feedback.
25 | ///
26 | public const string RecordingFeedback = FeedbackEndpointBase + "/recording-feedback";
27 |
28 | ///
29 | /// Endpoint for user feedback.
30 | ///
31 | public const string UserFeedback = FeedbackEndpointBase + "/user/{0}/get-feedback";
32 |
33 | ///
34 | /// User endpoint base.
35 | ///
36 | private const string UserEndpointBase = "user";
37 |
38 | ///
39 | /// Endpoint for user listens.
40 | ///
41 | public const string ListensEndpoint = UserEndpointBase + "/{0}/listens";
42 | }
43 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/FeedbackScore.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
2 |
3 | ///
4 | /// Accepted values for feedback score.
5 | ///
6 | public sealed class FeedbackScore
7 | {
8 | ///
9 | /// Neutral score, clears any score set previously.
10 | ///
11 | public static readonly FeedbackScore Neutral = new(0);
12 |
13 | ///
14 | /// Loved score.
15 | ///
16 | public static readonly FeedbackScore Loved = new(1);
17 |
18 | ///
19 | /// Hated score.
20 | ///
21 | public static readonly FeedbackScore Hated = new(-1);
22 |
23 | private FeedbackScore(int value)
24 | {
25 | Value = value;
26 | }
27 |
28 | ///
29 | /// Gets value.
30 | ///
31 | public int Value { get; }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/General.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
2 |
3 | ///
4 | /// ListenBrainz API resources.
5 | ///
6 | public static class General
7 | {
8 | ///
9 | /// API version.
10 | ///
11 | public const string Version = "1";
12 |
13 | ///
14 | /// API base URL.
15 | ///
16 | public const string BaseUrl = "https://api.listenbrainz.org";
17 | }
18 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/Headers.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
2 |
3 | ///
4 | /// ListenBrainz API custom headers.
5 | ///
6 | public static class Headers
7 | {
8 | ///
9 | /// Number of requests allowed in given time window.
10 | ///
11 | public const string RateLimitLimit = "X-RateLimit-Limit";
12 |
13 | ///
14 | /// Number of requests remaining in current time window.
15 | ///
16 | public const string RateLimitRemaining = "X-RateLimit-Remaining";
17 |
18 | ///
19 | /// Number of seconds when current time window expires (recommended: this header is resilient against clients with incorrect clocks).
20 | ///
21 | public const string RateLimitResetIn = "X-RateLimit-Reset-In";
22 |
23 | ///
24 | /// UNIX epoch number of seconds (without timezone) when current time window expires.
25 | ///
26 | public const string RateLimitReset = "X-RateLimit-Reset";
27 | }
28 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/Limits.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Exceptions;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
4 |
5 | ///
6 | /// ListenBrainz limits, thresholds, etc...
7 | ///
8 | public static class Limits
9 | {
10 | // TODO: these are configurable and should be a part of a config if connecting to a custom LB-compatible server.
11 |
12 | ///
13 | /// Maximum accepted duration of a listen.
14 | ///
15 | public const int MaxDurationLimit = 2073600;
16 |
17 | ///
18 | /// Maximum number of listens in a request.
19 | /// API docs states this limit is set to 1000, we will be a bit conservative.
20 | ///
21 | public const int MaxListensPerRequest = 100;
22 |
23 | ///
24 | /// Maximum number of items returned in a single GET request.
25 | ///
26 | public const int MaxItemsPerGet = 1000;
27 |
28 | ///
29 | /// Default number of items returned in a single GET request.
30 | ///
31 | public const int DefaultItemsPerGet = 25;
32 |
33 | ///
34 | /// Minimum acceptable value for listened_at field.
35 | ///
36 | public const int ListenMinimumTs = 1033430400;
37 |
38 | // ListenBrainz rules for submitting listens:
39 | // Listens should be submitted for tracks when the user has listened to half the track or 4 minutes of the track, whichever is lower.
40 | // If the user hasn't listened to 4 minutes or half the track, it doesn't fully count as a listen and should not be submitted.
41 | // https://listenbrainz.readthedocs.io/en/latest/users/api/core.html#post--1-submit-listens
42 |
43 | ///
44 | /// ListenBrainz condition A for listen submission - at least 4 minutes of playback.
45 | ///
46 | ///
47 | private const long MinPlayTimeTicks = 4 * TimeSpan.TicksPerMinute;
48 |
49 | ///
50 | /// ListenBrainz condition B for listen submission - at least 50% of track has been played.
51 | ///
52 | ///
53 | private const double MinPlayPercentage = 50.00;
54 |
55 | ///
56 | /// Convenience method to check if ListenBrainz submission conditions have been met.
57 | ///
58 | /// Playback position in track (in ticks).
59 | /// Track runtime (in ticks).
60 | /// Conditions have not been met.
61 | public static void AssertSubmitConditions(long playbackPosition, long runtime)
62 | {
63 | var playPercent = ((double)playbackPosition / runtime) * 100;
64 | if (playPercent >= MinPlayPercentage) return;
65 | if (playbackPosition >= MinPlayTimeTicks) return;
66 |
67 | var msg = $"Played {playPercent}% (== {playbackPosition} ticks), but required {MinPlayPercentage}% or {MinPlayTimeTicks} ticks";
68 | throw new ListenBrainzException(msg);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Api/Resources/ListenType.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Api.Resources;
2 |
3 | ///
4 | /// Listen types accepted by ListenBrainz endpoint.
5 | ///
6 | public sealed class ListenType
7 | {
8 | ///
9 | /// Single listen type.
10 | /// Used when sending a single listen.
11 | ///
12 | public static readonly ListenType Single = new("single");
13 |
14 | ///
15 | /// Playing now listen type.
16 | /// Used when sending a 'playing now' update.
17 | ///
18 | public static readonly ListenType PlayingNow = new("playing_now");
19 |
20 | ///
21 | /// Import listen type.
22 | /// Used when sending multiple listens at once.
23 | ///
24 | public static readonly ListenType Import = new("import");
25 |
26 | private ListenType(string value)
27 | {
28 | Value = value;
29 | }
30 |
31 | ///
32 | /// Gets value.
33 | ///
34 | public string Value { get; }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/DateUtils.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Common;
4 |
5 | ///
6 | /// DateTime utilities.
7 | ///
8 | public static class DateUtils
9 | {
10 | ///
11 | /// Gets get UNIX timestamp of .
12 | ///
13 | public static long CurrentTimestamp => new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
14 |
15 | ///
16 | /// Gets a today's date in ISO format (yyyy-MM-dd).
17 | ///
18 | public static string TodayIso => DateTime.Today.ToString("yyyy-MM-dd", DateTimeFormatInfo.InvariantInfo);
19 | }
20 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Exceptions/FatalException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Common.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when an unrecoverable error has occurred.
5 | ///
6 | public class FatalException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public FatalException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public FatalException(string msg) : base(msg)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public FatalException(string msg, Exception inner) : base(msg, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Exceptions/NoDataException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Common.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when there's no data available.
5 | ///
6 | public class NoDataException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public NoDataException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public NoDataException(string msg) : base(msg)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public NoDataException(string msg, Exception inner) : base(msg, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Exceptions/RateLimitException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Common.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when a service is rate limited.
5 | ///
6 | public class RateLimitException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public RateLimitException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public RateLimitException(string msg) : base(msg)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public RateLimitException(string msg, Exception inner) : base(msg, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Extensions/EnumerableExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Common.Extensions;
2 |
3 | ///
4 | /// Extensions for .
5 | ///
6 | public static class EnumerableExtensions
7 | {
8 | ///
9 | /// Filter values which are null and return of a non-nullable type.
10 | /// From: https://codereview.stackexchange.com/a/283504.
11 | ///
12 | /// Source enumerable.
13 | /// Type of enumerable element.
14 | /// Enumerable of non-nullable type.
15 | public static IEnumerable WhereNotNull(this IEnumerable source)
16 | {
17 | foreach (var item in source)
18 | {
19 | if (item is { } notNullItem)
20 | {
21 | yield return notNullItem;
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Extensions/LoggerExtensions.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Common.Exceptions;
2 | using Microsoft.Extensions.Logging;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Common.Extensions;
5 |
6 | ///
7 | /// Extensions for .
8 | ///
9 | public static class LoggerExtensions
10 | {
11 | ///
12 | /// Add a new scope for specified event ID.
13 | ///
14 | /// Logger instance.
15 | /// Event key. Defaults to "EventId" if null.
16 | /// Event value. Defaults to a value from if null.
17 | /// Disposable logger scope.
18 | public static IDisposable AddNewScope(this ILogger logger, string? eventKey = null, string? eventVal = null)
19 | {
20 | var key = eventKey ?? "EventId";
21 | var val = eventVal ?? Utils.GetNewId();
22 | var scopedLogger = logger.BeginScope(new Dictionary { { key, val } });
23 | return scopedLogger ?? throw new FatalException("Failed to initialize logger");
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Extensions/StringExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Common.Extensions;
4 |
5 | ///
6 | /// String extensions.
7 | ///
8 | public static class StringExtensions
9 | {
10 | ///
11 | /// Capitalize string.
12 | ///
13 | /// String to capitalize.
14 | /// Capitalized string.
15 | public static string Capitalize(this string s)
16 | {
17 | if (string.IsNullOrEmpty(s)) return s;
18 | return char.ToUpper(s[0], CultureInfo.InvariantCulture) + s[1..];
19 | }
20 |
21 | ///
22 | /// Converts string to kebab-case.
23 | ///
24 | /// String to convert.
25 | /// Converted string.
26 | /// Inspired by: https://stackoverflow.com/a/58576400.
27 | public static string ToKebabCase(this string str)
28 | {
29 | var newStr = string.Concat(str.Select((c, i) => i > 0 && char.IsUpper(c) ? "-" + c : c.ToString()));
30 | return newStr.ToLower(CultureInfo.InvariantCulture);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Jellyfin.Plugin.ListenBrainz.Common.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | true
8 | false
9 | AllEnabledByDefault
10 | ../../code.ruleset
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | all
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Common/Utils.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Common;
2 |
3 | ///
4 | /// Various functions which can be used across the project.
5 | ///
6 | public static class Utils
7 | {
8 | ///
9 | /// Get a new ID. The ID is 7 characters long.
10 | ///
11 | /// New ID.
12 | public static string GetNewId()
13 | {
14 | return Guid.NewGuid().ToString("N")[..7];
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Exceptions/InvalidResponseException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Http.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when maximum number of retries has been reached.
5 | ///
6 | public class InvalidResponseException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public InvalidResponseException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public InvalidResponseException(string message) : base(message)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public InvalidResponseException(string message, Exception inner) : base(message, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Exceptions/RetryException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Http.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when maximum number of retries has been reached.
5 | ///
6 | public class RetryException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public RetryException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public RetryException(string message) : base(message)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public RetryException(string message, Exception inner) : base(message, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/HttpClient.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using Jellyfin.Plugin.ListenBrainz.Http.Exceptions;
3 | using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
4 | using Jellyfin.Plugin.ListenBrainz.Http.Services;
5 | using Microsoft.Extensions.Logging;
6 |
7 | namespace Jellyfin.Plugin.ListenBrainz.Http;
8 |
9 | ///
10 | /// A custom HTTP client.
11 | /// Can be used as a base for application-specific API clients.
12 | ///
13 | public class HttpClient
14 | {
15 | private const int RetryBackoffSeconds = 3;
16 | private const int MaxRetries = 6;
17 |
18 | private readonly IHttpClientFactory _httpClientFactory;
19 | private readonly ILogger _logger;
20 | private readonly ISleepService _sleepService;
21 |
22 | private readonly List _retryStatuses = new()
23 | {
24 | HttpStatusCode.InternalServerError,
25 | HttpStatusCode.BadGateway,
26 | HttpStatusCode.ServiceUnavailable,
27 | HttpStatusCode.GatewayTimeout,
28 | HttpStatusCode.InsufficientStorage
29 | };
30 |
31 | ///
32 | /// Initializes a new instance of the class.
33 | ///
34 | /// HTTP client factory.
35 | /// Logger instance.
36 | /// Sleep service.
37 | public HttpClient(IHttpClientFactory httpClientFactory, ILogger logger, ISleepService? sleepService)
38 | {
39 | _httpClientFactory = httpClientFactory;
40 | _logger = logger;
41 | _sleepService = sleepService ?? new DefaultSleepService();
42 | }
43 |
44 | ///
45 | /// Send a HTTP request.
46 | ///
47 | /// Request to send.
48 | /// Cancellation token.
49 | /// Request response.
50 | /// Number of retries has been reached.
51 | /// Response is not available.
52 | public async Task SendRequest(
53 | HttpRequestMessage requestMessage,
54 | CancellationToken cancellationToken)
55 | {
56 | using var httpClient = _httpClientFactory.CreateClient();
57 | var requestId = Guid.NewGuid().ToString("N")[..7];
58 | using var scope = _logger.BeginScope(new Dictionary { { "HttpRequestId", requestId } });
59 | var retrySecs = 1;
60 |
61 | HttpResponseMessage? responseMessage = null;
62 | for (var retries = 0; retries < MaxRetries; retries++)
63 | {
64 | using var request = await Clone(requestMessage);
65 | await LogRequest(request);
66 |
67 | try
68 | {
69 | responseMessage = await httpClient.SendAsync(request, cancellationToken);
70 | }
71 | catch (OperationCanceledException ex)
72 | {
73 | _logger.LogWarning("Request has been cancelled");
74 | _logger.LogDebug(ex, "A cancellation exception was thrown when sending a request");
75 | }
76 | catch (Exception ex)
77 | {
78 | _logger.LogWarning("An error occured when sending a request: {Reason}", ex.Message);
79 | _logger.LogDebug(ex, "An exception was thrown when sending a request");
80 | break;
81 | }
82 |
83 | if (responseMessage is not null && !_retryStatuses.Contains(responseMessage.StatusCode))
84 | {
85 | _logger.LogDebug("Response status is {Status}, will not retry", responseMessage.StatusCode);
86 | break;
87 | }
88 |
89 | if (retries + 1 == MaxRetries) throw new RetryException("Retry limit reached");
90 |
91 | retrySecs *= RetryBackoffSeconds;
92 | _logger.LogWarning("Request failed, will retry after {Num} seconds", retrySecs);
93 | await _sleepService.SleepAsync(retrySecs);
94 | }
95 |
96 | if (responseMessage is null)
97 | {
98 | _logger.LogError("No response available, request failed?");
99 | throw new InvalidResponseException("Response is null");
100 | }
101 |
102 | await LogResponse(responseMessage);
103 | return responseMessage;
104 | }
105 |
106 | ///
107 | /// Clones a .
108 | /// Inspired by https://stackoverflow.com/a/65435043.
109 | ///
110 | /// HTTP request to clone.
111 | /// HTTP request clone.
112 | private static async Task Clone(HttpRequestMessage originalRequest)
113 | {
114 | var clonedRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
115 | if (originalRequest.Content is not null)
116 | {
117 | var stream = new MemoryStream();
118 | await originalRequest.Content.CopyToAsync(stream);
119 | stream.Position = 0;
120 | clonedRequest.Content = new StreamContent(stream);
121 | originalRequest.Content.Headers.ToList()
122 | .ForEach(header => clonedRequest.Content.Headers.Add(header.Key, header.Value));
123 | }
124 |
125 | clonedRequest.Version = originalRequest.Version;
126 |
127 | originalRequest.Options.ToList().ForEach(option =>
128 | clonedRequest.Options.Set(new HttpRequestOptionsKey(option.Key), option.Value?.ToString()));
129 |
130 | originalRequest.Headers.ToList().ForEach(header =>
131 | clonedRequest.Headers.TryAddWithoutValidation(header.Key, header.Value));
132 |
133 | return clonedRequest;
134 | }
135 |
136 | private async Task LogRequest(HttpRequestMessage requestMessage)
137 | {
138 | var requestData = "null";
139 | if (requestMessage.Content is not null) requestData = await requestMessage.Content.ReadAsStringAsync();
140 |
141 | _logger.LogDebug(
142 | "Sending request:\nMethod: {Method}\nURI: {Uri}\nData: {Data}",
143 | requestMessage.Method,
144 | requestMessage.RequestUri,
145 | requestData);
146 | }
147 |
148 | private async Task LogResponse(HttpResponseMessage responseMessage)
149 | {
150 | var responseData = await responseMessage.Content.ReadAsStringAsync();
151 | _logger.LogDebug(
152 | "Got response:\nStatus: {Status}\nData: {Data}",
153 | responseMessage.StatusCode,
154 | responseData);
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Interfaces/ISleepService.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
2 |
3 | ///
4 | /// Sleep service.
5 | ///
6 | public interface ISleepService
7 | {
8 | ///
9 | /// Suspends code execution for specified interval.
10 | ///
11 | /// Time interval in seconds.
12 | public void Sleep(int interval);
13 |
14 | ///
15 | /// Suspends code execution for specified interval.
16 | ///
17 | /// Time interval in seconds.
18 | /// A Task representing the asynchronous operation.
19 | public Task SleepAsync(int interval);
20 | }
21 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Jellyfin.Plugin.ListenBrainz.Http.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | true
8 | false
9 | AllEnabledByDefault
10 | ../../code.ruleset
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | all
21 | runtime; build; native; contentfiles; analyzers; buildtransitive
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Services/DefaultSleepService.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Http.Services;
4 |
5 | ///
6 | /// Implementation of .
7 | ///
8 | public class DefaultSleepService : ISleepService
9 | {
10 | ///
11 | public void Sleep(int interval)
12 | {
13 | Thread.Sleep(interval * 1000);
14 | }
15 |
16 | ///
17 | public async Task SleepAsync(int interval)
18 | {
19 | await Task.Delay(interval * 1000);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.Http/Utils.cs:
--------------------------------------------------------------------------------
1 | using System.Web;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Http;
4 |
5 | ///
6 | /// HTTP utils.
7 | ///
8 | public static class Utils
9 | {
10 | ///
11 | /// Convert dictionary to HTTP GET query.
12 | ///
13 | /// Query data.
14 | /// Query string.
15 | public static string ToHttpGetQuery(Dictionary requestData)
16 | {
17 | var query = string.Empty;
18 | var i = 0;
19 | foreach (var d in requestData)
20 | {
21 | var encodedKey = HttpUtility.UrlEncode(d.Key);
22 | var encodedValue = HttpUtility.UrlEncode(d.Value);
23 | query += $"{encodedKey}={encodedValue}";
24 | if (++i != requestData.Count) query += '&';
25 | }
26 |
27 | return query;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Interfaces/IMusicBrainzApiClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Requests;
2 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Responses;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
5 |
6 | ///
7 | /// MusicBrainz API client interface.
8 | ///
9 | public interface IMusicBrainzApiClient
10 | {
11 | ///
12 | /// Get recording MusicBrainz data.
13 | ///
14 | /// Recording request.
15 | /// Cancellation token.
16 | /// Recording response.
17 | public Task GetRecordingAsync(RecordingRequest request, CancellationToken cancellationToken);
18 | }
19 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Interfaces/IMusicBrainzRequest.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
2 |
3 | ///
4 | /// MusicBrainz request.
5 | ///
6 | public interface IMusicBrainzRequest
7 | {
8 | ///
9 | /// Gets request endpoint.
10 | ///
11 | public string Endpoint { get; }
12 |
13 | ///
14 | /// Gets API base URL for this request.
15 | ///
16 | public string BaseUrl { get; init; }
17 |
18 | ///
19 | /// Gets search query data.
20 | ///
21 | public virtual Dictionary SearchQuery => new();
22 | }
23 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Interfaces/IMusicBrainzResponse.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
2 |
3 | ///
4 | /// MusicBrainz response.
5 | ///
6 | public interface IMusicBrainzResponse
7 | {
8 | }
9 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | true
8 | false
9 | AllEnabledByDefault
10 | ../../code.ruleset
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | all
20 | runtime; build; native; contentfiles; analyzers; buildtransitive
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Json/KebabCaseNamingPolicy.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using Jellyfin.Plugin.ListenBrainz.Common.Extensions;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Json;
5 |
6 | ///
7 | /// Kebab case JSON naming policy.
8 | ///
9 | /// Inspired by: https://stackoverflow.com/a/58576400
10 | public class KebabCaseNamingPolicy : JsonNamingPolicy
11 | {
12 | ///
13 | /// Gets naming policy instance.
14 | ///
15 | public static KebabCaseNamingPolicy Instance { get; } = new();
16 |
17 | ///
18 | public override string ConvertName(string name) => name.ToKebabCase();
19 | }
20 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Models/ArtistCredit.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models;
4 |
5 | ///
6 | /// Artist credit.
7 | ///
8 | public class ArtistCredit
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public ArtistCredit()
14 | {
15 | this.JoinPhrase = string.Empty;
16 | this.Name = string.Empty;
17 | }
18 |
19 | ///
20 | /// Gets or sets join phrase.
21 | ///
22 | [JsonPropertyName("joinphrase")]
23 | public string JoinPhrase { get; set; }
24 |
25 | ///
26 | /// Gets or sets artist name.
27 | ///
28 | public string Name { get; set; }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Models/Recording.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models;
4 |
5 | ///
6 | /// MusicBrainz recording.
7 | ///
8 | public class Recording
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public Recording()
14 | {
15 | Mbid = string.Empty;
16 | ArtistCredits = new List();
17 | Isrcs = new List();
18 | }
19 |
20 | ///
21 | /// Gets or sets recording MBID.
22 | ///
23 | [JsonPropertyName("id")]
24 | public string Mbid { get; set; }
25 |
26 | ///
27 | /// Gets or sets search match score.
28 | ///
29 | public int Score { get; set; }
30 |
31 | ///
32 | /// Gets or sets artist credits.
33 | ///
34 | [JsonPropertyName("artist-credit")]
35 | public IEnumerable ArtistCredits { get; set; }
36 |
37 | ///
38 | /// Gets or sets ISRCs associated with this recording.
39 | ///
40 | public IEnumerable Isrcs { get; set; }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Models/Requests/RecordingRequest.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Resources;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Requests;
5 |
6 | ///
7 | /// Recording request.
8 | ///
9 | public class RecordingRequest : IMusicBrainzRequest
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | /// Track MBID.
15 | public RecordingRequest(string trackMbid)
16 | {
17 | BaseUrl = Api.BaseUrl;
18 | TrackMbid = trackMbid;
19 | }
20 |
21 | ///
22 | public string Endpoint => Endpoints.Recording;
23 |
24 | ///
25 | public string BaseUrl { get; init; }
26 |
27 | ///
28 | /// Gets track MBID.
29 | ///
30 | public string TrackMbid { get; }
31 |
32 | ///
33 | public Dictionary SearchQuery => new() { { "tid", this.TrackMbid } };
34 | }
35 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Models/Responses/RecordingResponse.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Requests;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Responses;
5 |
6 | ///
7 | /// Response for a .
8 | ///
9 | public class RecordingResponse : IMusicBrainzResponse
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | public RecordingResponse()
15 | {
16 | this.Recordings = new List();
17 | }
18 |
19 | ///
20 | /// Gets or sets response recordings.
21 | ///
22 | public IEnumerable Recordings { get; set; }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/MusicBrainzApiClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
2 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
3 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Requests;
4 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Responses;
5 | using Microsoft.Extensions.Logging;
6 |
7 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi;
8 |
9 | ///
10 | /// MusicBrainz API client.
11 | ///
12 | public class MusicBrainzApiClient : BaseClient, IMusicBrainzApiClient
13 | {
14 | ///
15 | /// Initializes a new instance of the class.
16 | ///
17 | /// Name of the client application.
18 | /// Version of the client application.
19 | /// Where the maintainer can be contacted.
20 | /// HTTP client factory.
21 | /// Logger instance.
22 | /// Sleep service.
23 | public MusicBrainzApiClient(
24 | string clientName,
25 | string clientVersion,
26 | string contactUrl,
27 | IHttpClientFactory httpClientFactory,
28 | ILogger logger,
29 | ISleepService? sleepService = null)
30 | : base(clientName, clientVersion, contactUrl, httpClientFactory, logger, sleepService)
31 | {
32 | }
33 |
34 | ///
35 | public async Task GetRecordingAsync(RecordingRequest request, CancellationToken cancellationToken)
36 | {
37 | return await Get(request, cancellationToken);
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Resources/Api.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Resources;
2 |
3 | ///
4 | /// Musicbrainz API resources.
5 | ///
6 | public static class Api
7 | {
8 | ///
9 | /// API version.
10 | ///
11 | public const string Version = "2";
12 |
13 | ///
14 | /// API base URL.
15 | ///
16 | public const string BaseUrl = "https://musicbrainz.org";
17 | }
18 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz.MusicBrainzApi/Resources/Endpoints.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Resources;
2 |
3 | ///
4 | /// API endpoints.
5 | ///
6 | public static class Endpoints
7 | {
8 | ///
9 | /// Recording endpoint.
10 | ///
11 | public const string Recording = "recording";
12 | }
13 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Clients/MusicBrainzClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using Jellyfin.Plugin.ListenBrainz.Exceptions;
3 | using Jellyfin.Plugin.ListenBrainz.Extensions;
4 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
5 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Interfaces;
6 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models.Requests;
7 | using MediaBrowser.Controller.Entities;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace Jellyfin.Plugin.ListenBrainz.Clients;
11 |
12 | ///
13 | /// MusicBrainz client for plugin.
14 | ///
15 | public class MusicBrainzClient : IMusicBrainzClient
16 | {
17 | private readonly ILogger _logger;
18 | private readonly IMusicBrainzApiClient _apiClient;
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | /// Logger instance.
24 | /// MusicBrainz API client.
25 | public MusicBrainzClient(ILogger logger, IMusicBrainzApiClient apiClient)
26 | {
27 | _logger = logger;
28 | _apiClient = apiClient;
29 | }
30 |
31 | ///
32 | /// Getting metadata failed.
33 | /// Invalid audio item data.
34 | /// Metadata not available.
35 | public AudioItemMetadata GetAudioItemMetadata(BaseItem item)
36 | {
37 | var trackMbid = item.GetTrackMbid();
38 | if (trackMbid is null)
39 | {
40 | throw new ArgumentException("Audio item does not have a track MBID");
41 | }
42 |
43 | var config = Plugin.GetConfiguration();
44 | var request = new RecordingRequest(trackMbid) { BaseUrl = config.MusicBrainzApiUrl };
45 | var task = _apiClient.GetRecordingAsync(request, CancellationToken.None);
46 | task.Wait();
47 | if (task.Exception is not null)
48 | {
49 | throw task.Exception;
50 | }
51 |
52 | if (task.Result is null)
53 | {
54 | throw new MetadataException("No response received");
55 | }
56 |
57 | if (!task.Result.Recordings.Any())
58 | {
59 | throw new MetadataException("No metadata in response");
60 | }
61 |
62 | return new AudioItemMetadata(task.Result.Recordings.First());
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Clients/Utils.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api;
2 | using Jellyfin.Plugin.ListenBrainz.Common.Extensions;
3 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
4 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi;
5 | using Jellyfin.Plugin.ListenBrainz.Services;
6 | using Microsoft.Extensions.Logging;
7 | using UnderlyingClient = Jellyfin.Plugin.ListenBrainz.Http.HttpClient;
8 |
9 | namespace Jellyfin.Plugin.ListenBrainz.Clients;
10 |
11 | ///
12 | /// Client utils.
13 | ///
14 | public static class Utils
15 | {
16 | ///
17 | /// Get a ListenBrainz client.
18 | ///
19 | /// Logger instance.
20 | /// HTTP client factory.
21 | /// ListenBrainz client.
22 | public static IListenBrainzClient GetListenBrainzClient(
23 | ILogger logger,
24 | IHttpClientFactory clientFactory)
25 | {
26 | var httpClient = new UnderlyingClient(clientFactory, logger, null);
27 | var baseClient = new BaseApiClient(new HttpClientWrapper(httpClient), logger, null);
28 | var apiClient = new ListenBrainzApiClient(baseClient, logger);
29 | var pluginConfig = new DefaultPluginConfigService();
30 | return new ListenBrainzClient(logger, apiClient, pluginConfig);
31 | }
32 |
33 | ///
34 | /// Get a MusicBrainz client.
35 | ///
36 | /// Logger instance.
37 | /// HTTP client factory.
38 | /// Instance of .
39 | public static IMusicBrainzClient GetMusicBrainzClient(ILogger logger, IHttpClientFactory clientFactory)
40 | {
41 | var clientName = string.Join(string.Empty, Plugin.FullName.Split(' ').Select(s => s.Capitalize()));
42 | var apiClient = new MusicBrainzApiClient(clientName, Plugin.Version, Plugin.SourceUrl, clientFactory, logger);
43 | return new MusicBrainzClient(logger, apiClient);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Configuration/LibraryConfig.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Configuration;
2 |
3 | ///
4 | /// Jellyfin media library configuration for plugin.
5 | ///
6 | public class LibraryConfig
7 | {
8 | ///
9 | /// Gets or sets Jellyfin library ID.
10 | ///
11 | public Guid Id { get; set; }
12 |
13 | ///
14 | /// Gets or sets a value indicating whether this library should not be ignored by plugin.
15 | ///
16 | public bool IsAllowed { get; set; }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Configuration/PluginConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using System.Diagnostics.CodeAnalysis;
3 | using System.Text.Json.Serialization;
4 | using System.Xml.Serialization;
5 | using MediaBrowser.Model.Plugins;
6 |
7 | namespace Jellyfin.Plugin.ListenBrainz.Configuration;
8 |
9 | ///
10 | /// ListenBrainz plugin configuration.
11 | ///
12 | public class PluginConfiguration : BasePluginConfiguration
13 | {
14 | private string? _musicBrainzUrlOverride;
15 | private string? _listenBrainzUrlOverride;
16 | private bool? _isMusicBrainzEnabledOverride;
17 | private bool? _isAlternativeModeEnabled;
18 | private bool? _isImmediateFavoriteSyncEnabled;
19 |
20 | ///
21 | /// Initializes a new instance of the class.
22 | ///
23 | public PluginConfiguration()
24 | {
25 | UserConfigs = new Collection();
26 | LibraryConfigs = new Collection();
27 | BackupPath = string.Empty;
28 | }
29 |
30 | ///
31 | /// Gets or sets ListenBrainz API base URL.
32 | ///
33 | public string ListenBrainzApiUrl
34 | {
35 | get => _listenBrainzUrlOverride ?? Api.Resources.General.BaseUrl;
36 | set => _listenBrainzUrlOverride = value;
37 | }
38 |
39 | ///
40 | /// Gets a default ListenBrainz API base URL.
41 | ///
42 | [XmlIgnore]
43 | public string DefaultListenBrainzApiUrl => Api.Resources.General.BaseUrl;
44 |
45 | ///
46 | /// Gets or sets MusicBrainz API base URL.
47 | ///
48 | public string MusicBrainzApiUrl
49 | {
50 | get => _musicBrainzUrlOverride ?? MusicBrainzApi.Resources.Api.BaseUrl;
51 | set => _musicBrainzUrlOverride = value;
52 | }
53 |
54 | ///
55 | /// Gets a default MusicBrainz API base URL.
56 | ///
57 | [XmlIgnore]
58 | public string DefaultMusicBrainzApiUrl => MusicBrainzApi.Resources.Api.BaseUrl;
59 |
60 | ///
61 | /// Gets or sets a value indicating whether MusicBrainz integration is enabled.
62 | ///
63 | public bool IsMusicBrainzEnabled
64 | {
65 | get => _isMusicBrainzEnabledOverride ?? true;
66 | set => _isMusicBrainzEnabledOverride = value;
67 | }
68 |
69 | ///
70 | /// Gets or sets a value indicating whether alternative plugin mode is enabled.
71 | ///
72 | public bool IsAlternativeModeEnabled
73 | {
74 | get => _isAlternativeModeEnabled ?? false;
75 | set => _isAlternativeModeEnabled = value;
76 | }
77 |
78 | ///
79 | /// Gets or sets a value indicating whether immediate favorite sync is enabled.
80 | ///
81 | public bool IsImmediateFavoriteSyncEnabled
82 | {
83 | get => _isImmediateFavoriteSyncEnabled ?? true;
84 | set => _isImmediateFavoriteSyncEnabled = value;
85 | }
86 |
87 | ///
88 | /// Gets or sets ListenBrainz user configurations.
89 | ///
90 | [SuppressMessage("Warning", "CA2227", Justification = "Needed for deserialization")]
91 | public Collection UserConfigs { get; set; }
92 |
93 | ///
94 | /// Gets or sets configurations of Jellyfin libraries for this plugin.
95 | ///
96 | [SuppressMessage("Warning", "CA2227", Justification = "Needed for deserialization")]
97 | public Collection LibraryConfigs { get; set; }
98 |
99 | ///
100 | /// Gets or sets backup path.
101 | ///
102 | public string BackupPath { get; set; }
103 |
104 | ///
105 | /// Gets a value indicating whether backup feature is enabled.
106 | ///
107 | [JsonIgnore]
108 | [XmlIgnore]
109 | public bool IsBackupEnabled => !string.IsNullOrEmpty(BackupPath);
110 | }
111 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Configuration/UserConfig.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 | using System.Text.Json.Serialization;
3 | using System.Xml.Serialization;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Configuration;
6 |
7 | ///
8 | /// ListenBrainz user configuration.
9 | ///
10 | public class UserConfig
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | public UserConfig()
16 | {
17 | IsListenSubmitEnabled = false;
18 | ApiToken = string.Empty;
19 | UserName = string.Empty;
20 | IsBackupEnabled = false;
21 | }
22 |
23 | ///
24 | /// Gets or sets Jellyfin user id.
25 | ///
26 | public Guid JellyfinUserId { get; set; }
27 |
28 | ///
29 | /// Gets or sets ListenBrainz API token.
30 | ///
31 | public string ApiToken { get; set; }
32 |
33 | ///
34 | /// Gets or sets ListenBrainz API token in plaintext.
35 | ///
36 | [JsonIgnore]
37 | [XmlIgnore]
38 | public string PlaintextApiToken
39 | {
40 | get => Encoding.UTF8.GetString(Convert.FromBase64String(ApiToken));
41 | set => ApiToken = Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
42 | }
43 |
44 | ///
45 | /// Gets or sets a value indicating whether ListenBrainz submission is enabled.
46 | ///
47 | public bool IsListenSubmitEnabled { get; set; }
48 |
49 | ///
50 | /// Gets a value indicating whether ListenBrainz submission is not enabled.
51 | ///
52 | [JsonIgnore]
53 | [XmlIgnore]
54 | public bool IsNotListenSubmitEnabled => !IsListenSubmitEnabled;
55 |
56 | ///
57 | /// Gets or sets a value indicating whether ListenBrainz favorites sync is enabled.
58 | ///
59 | public bool IsFavoritesSyncEnabled { get; set; }
60 |
61 | ///
62 | /// Gets or sets a ListenBrainz username.
63 | ///
64 | public string UserName { get; set; }
65 |
66 | ///
67 | /// Gets or sets a value indicating whether listens should be backed up.
68 | ///
69 | public bool IsBackupEnabled { get; set; }
70 |
71 | ///
72 | /// Gets a value indicating whether listens backup is not enabled.
73 | ///
74 | [JsonIgnore]
75 | [XmlIgnore]
76 | public bool IsNotBackupEnabled => !IsBackupEnabled;
77 | }
78 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Configuration/styles.css:
--------------------------------------------------------------------------------
1 | .vertical-center {
2 | align-self: center;
3 | }
4 |
5 | .horizontal-center {
6 | justify-content: center;
7 | text-align: center;
8 | }
9 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Controllers/InternalController.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 | using Jellyfin.Plugin.ListenBrainz.Dtos;
3 | using Jellyfin.Plugin.ListenBrainz.Extensions;
4 | using MediaBrowser.Controller.Entities;
5 | using MediaBrowser.Controller.Library;
6 | using Microsoft.AspNetCore.Mvc;
7 |
8 | namespace Jellyfin.Plugin.ListenBrainz.Controllers;
9 |
10 | ///
11 | /// Controller for serving internal plugin resources.
12 | ///
13 | [ApiController]
14 | [Route("ListenBrainzPlugin/internal")]
15 | public class InternalController : ControllerBase
16 | {
17 | private readonly ILibraryManager _libraryManager;
18 |
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// Library manager.
23 | public InternalController(ILibraryManager libraryManager)
24 | {
25 | _libraryManager = libraryManager;
26 | }
27 |
28 | ///
29 | /// Load CSS from specified file and return it in response.
30 | ///
31 | /// CSS file name.
32 | /// CSS stylesheet file response.
33 | [Route("styles/{fileName}")]
34 | public ActionResult GetStyles([FromRoute] string fileName)
35 | {
36 | var assembly = Assembly.GetExecutingAssembly();
37 | string resourcePath = assembly.GetManifestResourceNames().Single(str => str.EndsWith(fileName, StringComparison.InvariantCulture));
38 | var stream = assembly.GetManifestResourceStream(resourcePath);
39 | if (stream is null) return NotFound();
40 | return new FileStreamResult(stream, "text/css");
41 | }
42 |
43 | ///
44 | /// Get all libraries in Jellyfin.
45 | ///
46 | /// Collection of all music libraries.
47 | [HttpGet]
48 | [Produces("application/json")]
49 | [Route("libraries")]
50 | public Task> GetLibraries()
51 | {
52 | return Task.FromResult(
53 | _libraryManager
54 | .GetLibraries()
55 | .Cast()
56 | .Select(ml => new JellyfinMediaLibrary(ml)));
57 | }
58 |
59 | ///
60 | /// Get all music libraries in Jellyfin.
61 | ///
62 | /// Collection of all music libraries.
63 | [HttpGet]
64 | [Produces("application/json")]
65 | [Route("musicLibraries")]
66 | public Task> GetMusicLibraries()
67 | {
68 | return Task.FromResult(
69 | _libraryManager
70 | .GetMusicLibraries()
71 | .Cast()
72 | .Select(ml => new JellyfinMediaLibrary(ml)));
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Controllers/PluginController.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
3 | using Microsoft.AspNetCore.Mvc;
4 | using Microsoft.Extensions.Logging;
5 | using ClientUtils = Jellyfin.Plugin.ListenBrainz.Clients.Utils;
6 |
7 | namespace Jellyfin.Plugin.ListenBrainz.Controllers;
8 |
9 | ///
10 | /// Controller for serving internal plugin resources.
11 | ///
12 | [ApiController]
13 | [Route("ListenBrainzPlugin")]
14 | public class PluginController : ControllerBase
15 | {
16 | private readonly ILogger _logger;
17 | private readonly IListenBrainzClient _client;
18 |
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// Logger factory.
23 | /// HTTP client factory.
24 | public PluginController(ILoggerFactory loggerFactory, IHttpClientFactory clientFactory)
25 | {
26 | _logger = loggerFactory.CreateLogger($"{Plugin.LoggerCategory}.Controller");
27 | _client = ClientUtils.GetListenBrainzClient(_logger, clientFactory);
28 | }
29 |
30 | ///
31 | /// Validate ListenBrainz API token.
32 | ///
33 | /// Token to verify.
34 | /// response.
35 | [HttpPost]
36 | [Route("ValidateToken")]
37 | [Consumes("application/json")]
38 | public async Task ValidateToken([FromBody] string apiToken)
39 | {
40 | try
41 | {
42 | return await _client.ValidateToken(apiToken);
43 | }
44 | catch (Exception e)
45 | {
46 | _logger.LogInformation("Token verification failed: {Reason}", e.Message);
47 | _logger.LogDebug(e, "Token verification failed");
48 | }
49 |
50 | return null;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/ArtistCredit.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
2 |
3 | ///
4 | /// Artist credit data.
5 | ///
6 | public class ArtistCredit
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | /// Artist name.
12 | /// Artist join phrase for full credit string.
13 | public ArtistCredit(string name, string joinPhrase = "")
14 | {
15 | Name = name;
16 | JoinPhrase = joinPhrase;
17 | }
18 |
19 | ///
20 | /// Gets join phrase for this artist.
21 | ///
22 | public string JoinPhrase { get; }
23 |
24 | ///
25 | /// Gets artist name.
26 | ///
27 | public string Name { get; }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/AudioItemMetadata.cs:
--------------------------------------------------------------------------------
1 | using System.Text;
2 | using System.Text.Json.Serialization;
3 | using Jellyfin.Plugin.ListenBrainz.MusicBrainzApi.Models;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
6 |
7 | ///
8 | /// Additional audio item metadata.
9 | ///
10 | public class AudioItemMetadata
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | public AudioItemMetadata()
16 | {
17 | RecordingMbid = string.Empty;
18 | ArtistCredits = new List();
19 | Isrcs = new List();
20 | }
21 |
22 | ///
23 | /// Initializes a new instance of the class.
24 | ///
25 | /// MBID recording data.
26 | public AudioItemMetadata(Recording recording)
27 | {
28 | RecordingMbid = recording.Mbid;
29 | ArtistCredits = recording.ArtistCredits.Select(r => new ArtistCredit(r.Name, r.JoinPhrase));
30 | Isrcs = recording.Isrcs;
31 | }
32 |
33 | ///
34 | /// Gets recording MBID for this audio item.
35 | ///
36 | public string RecordingMbid { get; init; }
37 |
38 | ///
39 | /// Gets all artists associated with this audio item.
40 | ///
41 | public IEnumerable ArtistCredits { get; init; }
42 |
43 | ///
44 | /// Gets full artist credit string using artist names and join phrases.
45 | ///
46 | /// Artist credit string.
47 | [JsonIgnore]
48 | public string FullCreditString
49 | {
50 | get
51 | {
52 | var creditString = new StringBuilder();
53 | foreach (var artistCredit in this.ArtistCredits)
54 | {
55 | creditString.Append(artistCredit.Name);
56 | creditString.Append(artistCredit.JoinPhrase);
57 | }
58 |
59 | return creditString.ToString();
60 | }
61 | }
62 |
63 | ///
64 | /// Gets or sets ISRCs associated with this audio item.
65 | ///
66 | public IEnumerable Isrcs { get; set; }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/JellyfinMediaLibrary.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Entities;
2 |
3 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
4 |
5 | ///
6 | /// Jellyfin media library.
7 | ///
8 | public class JellyfinMediaLibrary
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public JellyfinMediaLibrary()
14 | {
15 | Name = string.Empty;
16 | LibraryType = string.Empty;
17 | }
18 |
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// Jellyfin item/folder.
23 | public JellyfinMediaLibrary(CollectionFolder item)
24 | {
25 | Name = item.Name;
26 | Id = item.Id;
27 | LibraryType = item.CollectionType.Value.ToString();
28 | }
29 |
30 | ///
31 | /// Gets or sets library name.
32 | ///
33 | public string Name { get; set; }
34 |
35 | ///
36 | /// Gets or sets library ID.
37 | ///
38 | public Guid Id { get; set; }
39 |
40 | ///
41 | /// Gets or sets library type.
42 | ///
43 | public string LibraryType { get; set; }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/StoredListen.cs:
--------------------------------------------------------------------------------
1 | using MediaBrowser.Controller.Entities.Audio;
2 | using Newtonsoft.Json;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
5 |
6 | ///
7 | /// Stored listen of a Jellyfin item.
8 | ///
9 | public class StoredListen
10 | {
11 | ///
12 | /// Gets or sets ID of an item associated with this listen.
13 | ///
14 | public Guid Id { get; set; }
15 |
16 | ///
17 | /// Gets or sets UNIX timestamp when this listen has been created.
18 | ///
19 | public long ListenedAt { get; set; }
20 |
21 | ///
22 | /// Gets or sets additional metadata for item.
23 | ///
24 | public AudioItemMetadata? Metadata { get; set; }
25 |
26 | ///
27 | /// Gets a value indicating whether this listen has MusicBrainz recording ID.
28 | ///
29 | [JsonIgnore]
30 | public bool HasRecordingMbid => !string.IsNullOrEmpty(Metadata?.RecordingMbid);
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/TrackedItem.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
2 |
3 | ///
4 | /// Tracked item.
5 | ///
6 | public class TrackedItem
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public TrackedItem()
12 | {
13 | UserId = string.Empty;
14 | ItemId = string.Empty;
15 | }
16 |
17 | ///
18 | /// Gets Jellyfin user ID associated with this tracking.
19 | ///
20 | public string UserId { get; init; }
21 |
22 | ///
23 | /// Gets item ID this tracking is for.
24 | ///
25 | public string ItemId { get; init; }
26 |
27 | ///
28 | /// Gets UNIX timestamp of when the tracking started.
29 | ///
30 | public long StartedAt { get; init; }
31 |
32 | ///
33 | /// Gets UNIX timestamp indicating when the tracking for this item can be stopped.
34 | ///
35 | public long RemoveAfter { get; init; }
36 |
37 | ///
38 | /// Gets or sets a value indicating whether tracking is valid.
39 | /// Invalid tracked item is effectively a tracking waiting for removal
40 | /// and should not be taken into account.
41 | ///
42 | public bool IsValid { get; set; }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Dtos/ValidatedToken.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Dtos;
2 |
3 | ///
4 | /// Validated token.
5 | ///
6 | public class ValidatedToken
7 | {
8 | ///
9 | /// Gets a value indicating whether token is valid.
10 | ///
11 | public bool IsValid { get; init; }
12 |
13 | ///
14 | /// Gets the reason of token being invalid.
15 | ///
16 | public string? Reason { get; init; }
17 |
18 | ///
19 | /// Gets a username associated with the token.
20 | ///
21 | public string? UserName { get; init; }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Exceptions/MetadataException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Exceptions;
2 |
3 | ///
4 | /// Exception thrown when metadata are invalid or missing.
5 | ///
6 | public class MetadataException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public MetadataException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public MetadataException(string message) : base(message)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public MetadataException(string message, Exception inner) : base(message, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Exceptions/PluginException.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Exceptions;
2 |
3 | ///
4 | /// ListenBrainz plugin general exception.
5 | ///
6 | public class PluginException : Exception
7 | {
8 | ///
9 | /// Initializes a new instance of the class.
10 | ///
11 | public PluginException()
12 | {
13 | }
14 |
15 | ///
16 | /// Initializes a new instance of the class.
17 | ///
18 | /// Exception message.
19 | public PluginException(string message) : base(message)
20 | {
21 | }
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Exception message.
27 | /// Inner exception.
28 | public PluginException(string message, Exception inner) : base(message, inner)
29 | {
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Extensions/AudioExtensions.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Models;
2 | using Jellyfin.Plugin.ListenBrainz.Dtos;
3 | using MediaBrowser.Controller.Entities.Audio;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Extensions;
6 |
7 | ///
8 | /// Extensions for type.
9 | ///
10 | public static class AudioExtensions
11 | {
12 | ///
13 | /// Assert this item has required metadata for ListenBrainz submission.
14 | ///
15 | /// Audio item.
16 | /// Item does not have required data.
17 | public static void AssertHasMetadata(this Audio item)
18 | {
19 | var artistNames = item.Artists.TakeWhile(name => !string.IsNullOrEmpty(name));
20 | if (!artistNames.Any())
21 | {
22 | throw new ArgumentException("Item has no artists");
23 | }
24 |
25 | if (string.IsNullOrWhiteSpace(item.Name))
26 | {
27 | throw new ArgumentException("Item name is empty");
28 | }
29 | }
30 |
31 | ///
32 | /// Transforms an item to a .
33 | ///
34 | /// Item to transform.
35 | /// Timestamp of the listen.
36 | /// Additional item metadata.
37 | /// Listen instance with data from the item.
38 | public static Listen AsListen(this Audio item, long? timestamp = null, AudioItemMetadata? itemMetadata = null)
39 | {
40 | string allArtists = string.Join(", ", item.Artists.TakeWhile(name => !string.IsNullOrEmpty(name)));
41 | return new Listen
42 | {
43 | ListenedAt = timestamp,
44 | TrackMetadata = new TrackMetadata
45 | {
46 | ArtistName = itemMetadata?.FullCreditString ?? allArtists,
47 | ReleaseName = item.Album,
48 | TrackName = item.Name,
49 | AdditionalInfo = new AdditionalInfo
50 | {
51 | MediaPlayer = "Jellyfin",
52 | MediaPlayerVersion = null,
53 | SubmissionClient = Plugin.FullName,
54 | SubmissionClientVersion = Plugin.Version,
55 | ReleaseMbid = item.ProviderIds.GetValueOrDefault("MusicBrainzAlbum"),
56 | ArtistMbids = item.ProviderIds.GetValueOrDefault("MusicBrainzArtist")?.Split(';', '/', ',', (char)0x1F).Select(s => s.Trim()).ToArray(),
57 | ReleaseGroupMbid = item.ProviderIds.GetValueOrDefault("MusicBrainzReleaseGroup"),
58 | RecordingMbid = itemMetadata?.RecordingMbid,
59 | TrackMbid = item.ProviderIds.GetValueOrDefault("MusicBrainzTrack"),
60 | WorkMbids = null,
61 | TrackNumber = item.IndexNumber,
62 | Isrc = itemMetadata?.Isrcs.FirstOrDefault(),
63 | Tags = item.Tags,
64 | DurationMs = (item.RunTimeTicks / TimeSpan.TicksPerSecond) * 1000
65 | }
66 | }
67 | };
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Extensions/BaseItemExtensions.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Common;
2 | using Jellyfin.Plugin.ListenBrainz.Dtos;
3 | using MediaBrowser.Controller.Entities;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Extensions;
6 |
7 | ///
8 | /// Extensions for type.
9 | ///
10 | public static class BaseItemExtensions
11 | {
12 | ///
13 | /// Convenience method to get a MusicBrainz track ID for this item.
14 | ///
15 | /// Audio item.
16 | /// Track MBID. Null if not available.
17 | public static string? GetTrackMbid(this BaseItem item) => item.ProviderIds.GetValueOrDefault("MusicBrainzTrack");
18 |
19 | ///
20 | /// Item runtime in seconds.
21 | ///
22 | /// Audio item.
23 | /// Runtime in seconds.
24 | public static long RuntimeSeconds(this BaseItem item) => TimeSpan.FromTicks(item.RunTimeTicks ?? 0).Seconds;
25 |
26 | ///
27 | /// Create a from this item.
28 | ///
29 | /// Item data source.
30 | /// UNIX timestamp of the listen.
31 | /// Additional metadata.
32 | /// An instance of corresponding to the item.
33 | public static StoredListen AsStoredListen(this BaseItem item, long timestamp, AudioItemMetadata? metadata)
34 | {
35 | return new StoredListen
36 | {
37 | Id = item.Id,
38 | ListenedAt = timestamp,
39 | Metadata = metadata
40 | };
41 | }
42 |
43 | ///
44 | /// Create a from this item.
45 | ///
46 | /// Item data source.
47 | /// Jellyfin user ID associated with this tracked item.
48 | /// An instance of .
49 | public static TrackedItem AsTrackedItem(this BaseItem item, string userId)
50 | {
51 | return new TrackedItem
52 | {
53 | UserId = userId,
54 | ItemId = item.Id.ToString(),
55 | StartedAt = DateUtils.CurrentTimestamp,
56 | RemoveAfter = DateUtils.CurrentTimestamp + (5 * item.RuntimeSeconds()),
57 | IsValid = true
58 | };
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Extensions/LibraryManagerExtensions.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Data.Enums;
2 | using Jellyfin.Plugin.ListenBrainz.Api.Models;
3 | using Jellyfin.Plugin.ListenBrainz.Dtos;
4 | using MediaBrowser.Controller.Entities;
5 | using MediaBrowser.Controller.Entities.Audio;
6 | using MediaBrowser.Controller.Library;
7 |
8 | namespace Jellyfin.Plugin.ListenBrainz.Extensions;
9 |
10 | ///
11 | /// Extensions for library manager.
12 | ///
13 | public static class LibraryManagerExtensions
14 | {
15 | ///
16 | /// Get all libraries.
17 | ///
18 | /// Jellyfin library manager.
19 | /// All music libraries on server.
20 | public static IEnumerable GetLibraries(this ILibraryManager libraryManager)
21 | {
22 | return libraryManager
23 | .GetUserRootFolder()
24 | .Children
25 | .Cast();
26 | }
27 |
28 | ///
29 | /// Get all music libraries.
30 | ///
31 | /// Jellyfin library manager.
32 | /// All music libraries on server.
33 | public static IEnumerable GetMusicLibraries(this ILibraryManager libraryManager)
34 | {
35 | return GetLibraries(libraryManager)
36 | .Cast()
37 | .Where(f => f.CollectionType == CollectionType.music);
38 | }
39 |
40 | ///
41 | /// Convert StoredListen to Listen.
42 | ///
43 | /// Library manager instance.
44 | /// Stored listen to convert.
45 | /// Listen corresponding to provided stored listen. Null if conversion failed.
46 | public static Listen? ToListen(this ILibraryManager libraryManager, StoredListen listen)
47 | {
48 | var baseItem = libraryManager.GetItemById(listen.Id);
49 | try
50 | {
51 | var audio = (Audio?)baseItem;
52 | return audio?.AsListen(listen.ListenedAt, listen.Metadata);
53 | }
54 | catch (InvalidCastException)
55 | {
56 | return null;
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Extensions/UserExtensions.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Data.Entities;
2 | using Jellyfin.Plugin.ListenBrainz.Configuration;
3 | using Jellyfin.Plugin.ListenBrainz.Exceptions;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Extensions;
6 |
7 | ///
8 | /// Extensions for type.
9 | ///
10 | public static class UserExtensions
11 | {
12 | ///
13 | /// Get ListenBrainz config for this user.
14 | ///
15 | /// Jellyfin user.
16 | /// ListenBrainz config. Null if not available.
17 | public static UserConfig GetListenBrainzConfig(this User user)
18 | {
19 | var userConfig = Plugin
20 | .GetConfiguration()
21 | .UserConfigs
22 | .FirstOrDefault(u => u.JellyfinUserId == user.Id);
23 |
24 | if (userConfig is null)
25 | {
26 | throw new PluginException("User configuration is not available (unconfigured user?)");
27 | }
28 |
29 | return userConfig;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IBackupManager.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using Jellyfin.Plugin.ListenBrainz.Exceptions;
3 | using MediaBrowser.Controller.Entities.Audio;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
6 |
7 | ///
8 | /// Backup manager interface.
9 | ///
10 | public interface IBackupManager : IDisposable
11 | {
12 | ///
13 | /// Back up listen of a current item.
14 | ///
15 | /// ListenBrainz username.
16 | /// Listened item.
17 | /// Item metadata.
18 | /// Listen timestamp.
19 | /// Backup failed.
20 | public void Backup(string userName, Audio item, AudioItemMetadata? metadata, long timestamp);
21 | }
22 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/ICacheManager.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
2 |
3 | ///
4 | /// Cache manager.
5 | ///
6 | public interface ICacheManager
7 | {
8 | ///
9 | /// Save cache content to a file on disk.
10 | ///
11 | public void Save();
12 |
13 | ///
14 | /// Save cache content to a file on disk.
15 | ///
16 | /// Task representing asynchronous operation.
17 | public Task SaveAsync();
18 |
19 | ///
20 | /// Restore cache content from a file on disk.
21 | ///
22 | public void Restore();
23 |
24 | ///
25 | /// Restore cache content from a file on disk.
26 | ///
27 | /// Task representing asynchronous operation.
28 | public Task RestoreAsync();
29 | }
30 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IFavoriteSyncService.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
2 |
3 | ///
4 | /// Service for syncing favorites.
5 | ///
6 | public interface IFavoriteSyncService
7 | {
8 | ///
9 | /// Gets a value indicating whether the service is enabled.
10 | ///
11 | bool IsEnabled { get; }
12 |
13 | ///
14 | /// Gets a value indicating whether the service is disabled.
15 | ///
16 | bool IsDisabled { get; }
17 |
18 | ///
19 | /// Syncs a favorite Jellyfin track to a loved ListenBrainz recording.
20 | ///
21 | /// ID of the audio item.
22 | /// ID of the Jellyfin user.
23 | /// Listen timestamp. If specified, MSID sync will be attempted if recording MBID is not available.
24 | public void SyncToListenBrainz(Guid itemId, Guid jellyfinUserId, long? listenTs = null);
25 |
26 | ///
27 | /// Enables the service.
28 | ///
29 | public void Enable();
30 |
31 | ///
32 | /// Disables the service.
33 | ///
34 | public void Disable();
35 | }
36 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IListenBrainzClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Api.Models;
2 | using Jellyfin.Plugin.ListenBrainz.Configuration;
3 | using Jellyfin.Plugin.ListenBrainz.Dtos;
4 | using MediaBrowser.Controller.Entities.Audio;
5 |
6 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
7 |
8 | ///
9 | /// ListenBrainz client.
10 | ///
11 | public interface IListenBrainzClient
12 | {
13 | ///
14 | /// Send 'now playing' listen of specified item.
15 | ///
16 | /// ListenBrainz user configuration.
17 | /// Audio item currently being listened to.
18 | /// Additional metadata for this audio item.
19 | /// Sending failed.
20 | public void SendNowPlaying(UserConfig config, Audio item, AudioItemMetadata? audioMetadata);
21 |
22 | ///
23 | /// Send a single listen of specified item.
24 | ///
25 | /// ListenBrainz user configuration.
26 | /// Audio item of the listen.
27 | /// Additional metadata for this audio item.
28 | /// Timestamp of the listen.
29 | /// Sending failed.
30 | public void SendListen(UserConfig config, Audio item, AudioItemMetadata? metadata, long listenedAt);
31 |
32 | ///
33 | /// Send a feedback for a specific recording, identified by either a MBID or MSID.
34 | ///
35 | /// ListenBrainz user configuration.
36 | /// The recording is marked as favorite.
37 | /// MusicBrainz ID identifying the recording.
38 | /// MessyBrainz ID identifying the recording.
39 | /// Sending failed.
40 | public void SendFeedback(UserConfig config, bool isFavorite, string? recordingMbid = null, string? recordingMsid = null);
41 |
42 | ///
43 | /// Send multiple listens ('import') to ListenBrainz.
44 | ///
45 | /// ListenBrainz user configuration.
46 | /// Listens to send.
47 | /// Cancellation token.
48 | /// Task representing asynchronous operation.
49 | public Task SendListensAsync(UserConfig config, IEnumerable listens, CancellationToken cancellationToken);
50 |
51 | ///
52 | /// Validate specified API token.
53 | ///
54 | /// Token to validate.
55 | /// Validated token.
56 | public Task ValidateToken(string apiToken);
57 |
58 | ///
59 | /// Get a recording MSID (MessyBrainz ID) associated with a listen submitted to ListenBrainz.
60 | ///
61 | /// ListenBrainz user configuration.
62 | /// Timestamp of the submitted listen.
63 | /// Recording MSID associated with a specified listen timestamp.
64 | public string GetRecordingMsidByListenTs(UserConfig config, long ts);
65 |
66 | ///
67 | /// Get a collection of recording MBIDs which are loved by the user.
68 | ///
69 | /// ListenBrainz user configuration.
70 | /// Cancellation token.
71 | /// List of recording MBIDs identifying loved tracks by the user.
72 | public Task> GetLovedTracksAsync(UserConfig config, CancellationToken cancellationToken);
73 | }
74 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IListensCache.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using MediaBrowser.Controller.Entities.Audio;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
5 |
6 | ///
7 | /// Cache for storing listens.
8 | ///
9 | public interface IListensCache
10 | {
11 | ///
12 | /// Add specified listen to cache.
13 | ///
14 | /// Jellyfin user ID associated with the listen.
15 | /// Audio item associated with the listen.
16 | /// Additional metadata for the item.
17 | /// UNIX timestamp when the listens occured.
18 | public void AddListen(Guid userId, Audio item, AudioItemMetadata? metadata, long listenedAt);
19 |
20 | ///
21 | /// Add specified listen to cache.
22 | ///
23 | /// Jellyfin user ID associated with the listen.
24 | /// Audio item associated with the listen.
25 | /// Additional metadata for the item.
26 | /// UNIX timestamp when the listens occured.
27 | /// Task representing asynchronous operation.
28 | public Task AddListenAsync(Guid userId, Audio item, AudioItemMetadata? metadata, long listenedAt);
29 |
30 | ///
31 | /// Get all listens in cache for specified user.
32 | ///
33 | /// Jellyfin user ID associated with the listens.
34 | /// Listens stored in cache.
35 | public IEnumerable GetListens(Guid userId);
36 |
37 | ///
38 | /// Remove specified listen from cache for specified user.
39 | ///
40 | /// Jellyfin user ID associated with the listen.
41 | /// Listen to remove.
42 | public void RemoveListen(Guid userId, StoredListen listen);
43 |
44 | ///
45 | /// Remove specified listens from cache for specified user.
46 | ///
47 | /// Jellyfin user ID associated with the listens.
48 | /// Listens to remove.
49 | public void RemoveListens(Guid userId, IEnumerable listens);
50 |
51 | ///
52 | /// Remove specified listens from cache for specified user.
53 | ///
54 | /// Jellyfin user ID associated with the listens.
55 | /// Listens to remove.
56 | /// Task representing asynchronous operation.
57 | public Task RemoveListensAsync(Guid userId, IEnumerable listens);
58 | }
59 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IListensCacheManager.cs:
--------------------------------------------------------------------------------
1 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
2 |
3 | ///
4 | /// Listen cache manager interface.
5 | ///
6 | public interface IListensCacheManager : ICacheManager, IListensCache, IDisposable
7 | {
8 | }
9 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IMusicBrainzClient.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using MediaBrowser.Controller.Entities;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
5 |
6 | ///
7 | /// MusicBrainz client.
8 | ///
9 | public interface IMusicBrainzClient
10 | {
11 | ///
12 | /// Get additional metadata for specified audio item.
13 | ///
14 | /// Audio item.
15 | /// Audio item metadata.
16 | public AudioItemMetadata GetAudioItemMetadata(BaseItem item);
17 | }
18 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Interfaces/IPluginConfigService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using Jellyfin.Plugin.ListenBrainz.Configuration;
3 |
4 | namespace Jellyfin.Plugin.ListenBrainz.Interfaces;
5 |
6 | ///
7 | /// A service for accessing plugin configuration.
8 | ///
9 | public interface IPluginConfigService
10 | {
11 | ///
12 | /// Gets a value indicating whether the alternative mode is enabled.
13 | ///
14 | public bool IsAlternativeModeEnabled { get; }
15 |
16 | ///
17 | /// Gets configured ListenBrainz API URL.
18 | ///
19 | /// ListenBrainz API URL.
20 | public string ListenBrainzApiUrl { get; }
21 |
22 | ///
23 | /// Gets a value indicating whether listen backup feature is enabled.
24 | ///
25 | bool IsBackupEnabled { get; }
26 |
27 | ///
28 | /// Gets a value indicating whether MusicBrainz integration is enabled.
29 | ///
30 | bool IsMusicBrainzEnabled { get; }
31 |
32 | ///
33 | /// Gets a value indicating whether the immediate favorite sync feature is enabled.
34 | ///
35 | bool IsImmediateFavoriteSyncEnabled { get; }
36 |
37 | ///
38 | /// Gets library configurations.
39 | ///
40 | Collection LibraryConfigs { get; }
41 |
42 | ///
43 | /// Gets all ListenBrainz user configurations.
44 | ///
45 | Collection UserConfigs { get; }
46 |
47 | ///
48 | /// Get a configuration for a specified Jellyfin user ID.
49 | ///
50 | /// ID of the Jellyfin user.
51 | /// User configuration. Null if it does not exist.
52 | public UserConfig? GetUserConfig(Guid jellyfinUserId);
53 | }
54 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Jellyfin.Plugin.ListenBrainz.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 | true
8 | false
9 | AllEnabledByDefault
10 | ../../code.ruleset
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | all
22 | runtime; build; native; contentfiles; analyzers; buildtransitive
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Managers/BackupManager.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using System.Text.Json.Serialization;
3 | using Jellyfin.Plugin.ListenBrainz.Api.Models;
4 | using Jellyfin.Plugin.ListenBrainz.Common;
5 | using Jellyfin.Plugin.ListenBrainz.Dtos;
6 | using Jellyfin.Plugin.ListenBrainz.Exceptions;
7 | using Jellyfin.Plugin.ListenBrainz.Extensions;
8 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
9 | using MediaBrowser.Controller.Entities.Audio;
10 | using Microsoft.Extensions.Logging;
11 |
12 | namespace Jellyfin.Plugin.ListenBrainz.Managers;
13 |
14 | ///
15 | /// Listens backup manager.
16 | ///
17 | public class BackupManager : IBackupManager
18 | {
19 | private readonly ILogger _logger;
20 | private readonly SemaphoreSlim _lock;
21 | private bool _isDisposed;
22 |
23 | ///
24 | /// JSON serializer options.
25 | ///
26 | private static readonly JsonSerializerOptions _serializerOptions = new()
27 | {
28 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
29 | PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
30 | };
31 |
32 | ///
33 | /// Initializes a new instance of the class.
34 | ///
35 | /// Logger.
36 | public BackupManager(ILogger logger)
37 | {
38 | _logger = logger;
39 | _lock = new SemaphoreSlim(1, 1);
40 | }
41 |
42 | ///
43 | /// Finalizes an instance of the class.
44 | ///
45 | ~BackupManager() => Dispose(false);
46 |
47 | ///
48 | public void Backup(string userName, Audio item, AudioItemMetadata? metadata, long timestamp)
49 | {
50 | var config = Plugin.GetConfiguration();
51 | var dirPath = Path.Combine(config.BackupPath, userName);
52 | var filePath = Path.Combine(dirPath, $"{DateUtils.TodayIso}.json");
53 | List? userListens = null;
54 |
55 | _logger.LogDebug("Backing up listen of {SongName} to {FileName}", item.Name, filePath);
56 |
57 | _lock.Wait();
58 | try
59 | {
60 | var dirInfo = new DirectoryInfo(dirPath);
61 | if (dirInfo.Exists)
62 | {
63 | using var stream = File.OpenRead(filePath);
64 | userListens = JsonSerializer.Deserialize>(stream, _serializerOptions);
65 | }
66 | else
67 | {
68 | _logger.LogDebug("Directory does not exist, it will be created");
69 | dirInfo.Create();
70 | }
71 | }
72 | catch (FileNotFoundException)
73 | {
74 | _logger.LogDebug("File does not exist, it will be created");
75 | }
76 | catch (Exception ex)
77 | {
78 | throw new PluginException("Failed to read backup file", ex);
79 | }
80 |
81 | userListens ??= new List();
82 | userListens.Add(item.AsListen(timestamp, metadata));
83 |
84 | try
85 | {
86 | using var stream = File.Create(filePath);
87 | JsonSerializer.Serialize(stream, userListens, _serializerOptions);
88 | }
89 | catch (Exception ex)
90 | {
91 | throw new PluginException("Listen backup failed", ex);
92 | }
93 | finally
94 | {
95 | _lock.Release();
96 | }
97 | }
98 |
99 | ///
100 | public void Dispose()
101 | {
102 | Dispose(true);
103 | GC.SuppressFinalize(this);
104 | }
105 |
106 | ///
107 | /// Dispose managed and unmanaged (own) resources.
108 | ///
109 | /// Dispose managed resources.
110 | private void Dispose(bool disposing)
111 | {
112 | if (_isDisposed)
113 | {
114 | return;
115 | }
116 |
117 | if (disposing)
118 | {
119 | _lock.Dispose();
120 | }
121 |
122 | _isDisposed = true;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Managers/PlaybackTrackingManager.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Dtos;
2 | using Jellyfin.Plugin.ListenBrainz.Extensions;
3 | using MediaBrowser.Controller.Entities.Audio;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Managers;
6 |
7 | ///
8 | /// Playback tracking manager.
9 | ///
10 | public class PlaybackTrackingManager
11 | {
12 | private readonly object _lock = new();
13 | private static PlaybackTrackingManager? _instance;
14 | private Dictionary> _items;
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | public PlaybackTrackingManager()
20 | {
21 | _items = new Dictionary>();
22 | }
23 |
24 | ///
25 | /// Gets instance of the cache manager.
26 | ///
27 | public static PlaybackTrackingManager Instance
28 | {
29 | get
30 | {
31 | if (_instance is null) _instance = new PlaybackTrackingManager();
32 | return _instance;
33 | }
34 | }
35 |
36 | ///
37 | /// Add item (start tracking) of a specified item for a specified user.
38 | ///
39 | /// ID of user to track the item for.
40 | /// Item to track.
41 | public void AddItem(string userId, Audio item)
42 | {
43 | try
44 | {
45 | Monitor.Enter(_lock);
46 | if (!_items.ContainsKey(userId)) _items.Add(userId, new List());
47 | if (_items[userId].Exists(i => i.ItemId == item.Id.ToString()))
48 | {
49 | _items[userId].RemoveAll(i => i.ItemId == item.Id.ToString());
50 | }
51 |
52 | _items[userId].Add(item.AsTrackedItem(userId));
53 | }
54 | finally
55 | {
56 | Monitor.Exit(_lock);
57 | }
58 | }
59 |
60 | ///
61 | /// Get tracked item of a specified item and user.
62 | ///
63 | /// ID of user the item is being tracked for.
64 | /// ID of the tracked item.
65 | /// Tracked item. Null if not found.
66 | public TrackedItem? GetItem(string userId, string itemId)
67 | {
68 | try
69 | {
70 | Monitor.Enter(_lock);
71 | if (!_items.ContainsKey(userId)) return null;
72 | return _items[userId].Find(v => v.ItemId == itemId);
73 | }
74 | finally
75 | {
76 | Monitor.Exit(_lock);
77 | }
78 | }
79 |
80 | ///
81 | /// Remove specified tracked item for specified user.
82 | ///
83 | /// ID of user the item is being tracked for.
84 | /// Tracked item to remove.
85 | public void RemoveItem(string userId, TrackedItem item)
86 | {
87 | try
88 | {
89 | Monitor.Enter(_lock);
90 | if (!_items.ContainsKey(userId)) return;
91 | _items[userId].Remove(item);
92 | }
93 | finally
94 | {
95 | Monitor.Exit(_lock);
96 | }
97 | }
98 |
99 | ///
100 | /// Invalidate specified item for specified user.
101 | ///
102 | /// Jellyfin user ID.
103 | /// Item to invalidate.
104 | public void InvalidateItem(string userId, TrackedItem item)
105 | {
106 | try
107 | {
108 | Monitor.Enter(_lock);
109 | if (!_items.ContainsKey(userId)) return;
110 | _items[userId].First(i => i.ItemId == item.ItemId).IsValid = false;
111 | }
112 | finally
113 | {
114 | Monitor.Exit(_lock);
115 | }
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Plugin.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.Reflection;
3 | using Jellyfin.Plugin.ListenBrainz.Configuration;
4 | using Jellyfin.Plugin.ListenBrainz.Exceptions;
5 | using MediaBrowser.Common.Configuration;
6 | using MediaBrowser.Common.Plugins;
7 | using MediaBrowser.Controller.Library;
8 | using MediaBrowser.Controller.Session;
9 | using MediaBrowser.Model.Plugins;
10 | using MediaBrowser.Model.Serialization;
11 | using Microsoft.Extensions.Hosting;
12 | using Microsoft.Extensions.Logging;
13 |
14 | namespace Jellyfin.Plugin.ListenBrainz;
15 |
16 | ///
17 | /// ListenBrainz Plugin definition for Jellyfin.
18 | ///
19 | public class Plugin : BasePlugin, IHasWebPages
20 | {
21 | private static Plugin? _thisInstance;
22 | private readonly IHostedService _service;
23 |
24 | ///
25 | /// Initializes a new instance of the class.
26 | ///
27 | /// Application paths.
28 | /// XML serializer.
29 | /// Session manager.
30 | /// Logger factory.
31 | /// HTTP client factory.
32 | /// User data manager.
33 | /// Library manager.
34 | /// User manager.
35 | /// Plugin service.
36 | public Plugin(
37 | IApplicationPaths paths,
38 | IXmlSerializer xmlSerializer,
39 | ISessionManager sessionManager,
40 | ILoggerFactory loggerFactory,
41 | IHttpClientFactory clientFactory,
42 | IUserDataManager userDataManager,
43 | ILibraryManager libraryManager,
44 | IUserManager userManager) : base(paths, xmlSerializer)
45 | {
46 | _thisInstance = this;
47 | _service = new PluginService(
48 | sessionManager,
49 | loggerFactory,
50 | clientFactory,
51 | userDataManager,
52 | libraryManager,
53 | userManager);
54 | _service.StartAsync(CancellationToken.None);
55 | }
56 |
57 | ///
58 | /// Finalizes an instance of the class.
59 | ///
60 | ~Plugin() => _service.StopAsync(CancellationToken.None);
61 |
62 | ///
63 | public override string Name => "ListenBrainz";
64 |
65 | ///
66 | public override Guid Id => Guid.Parse("59B20823-AAFE-454C-A393-17427F518631");
67 |
68 | ///
69 | /// Gets plugin version.
70 | ///
71 | public static new string Version
72 | {
73 | get => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0";
74 | }
75 |
76 | ///
77 | /// Gets full plugin name.
78 | ///
79 | public static string FullName => "ListenBrainz plugin for Jellyfin";
80 |
81 | ///
82 | /// Gets plugin source URL.
83 | ///
84 | public static string SourceUrl => "https://github.com/lyarenei/jellyfin-plugin-listenbrainz";
85 |
86 | ///
87 | /// Gets logger category.
88 | ///
89 | public static string LoggerCategory => "Jellyfin.Plugin.ListenBrainz";
90 |
91 | ///
92 | public IEnumerable GetPages()
93 | {
94 | return new[]
95 | {
96 | new PluginPageInfo
97 | {
98 | Name = Name,
99 | EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configurationPage.html",
100 | EnableInMainMenu = false,
101 | MenuIcon = "music_note"
102 | }
103 | };
104 | }
105 |
106 | ///
107 | /// Convenience method for getting plugin configuration.
108 | ///
109 | /// Plugin configuration.
110 | /// Plugin instance is not available.
111 | [Obsolete("Use PluginConfigurationService to access plugin configuration.")]
112 | public static PluginConfiguration GetConfiguration()
113 | {
114 | var config = _thisInstance?.Configuration;
115 | if (config is not null) return config;
116 | throw new PluginException("Plugin instance is not available");
117 | }
118 |
119 | ///
120 | /// Gets plugin data path.
121 | ///
122 | /// Path to the plugin data folder.
123 | /// Plugin instance is not available.
124 | public static string GetDataPath()
125 | {
126 | // DataFolderPath is invalid (https://github.com/jellyfin/jellyfin/issues/10091)
127 | // var path = _thisInstance?.DataFolderPath;
128 | var pluginDirName = string.Format(CultureInfo.InvariantCulture, "{0}_{1}", _thisInstance?.Name, Version);
129 | var path = Path.Join(_thisInstance?.ApplicationPaths.PluginsPath, pluginDirName);
130 | if (path is null) throw new PluginException("Plugin instance is not available");
131 | return path;
132 | }
133 |
134 | ///
135 | /// Gets plugin configuration directory path.
136 | ///
137 | /// Path to config directory.
138 | /// Plugin instance or path is not available.
139 | public static string GetConfigDirPath()
140 | {
141 | var path = _thisInstance?.ConfigurationFilePath;
142 | if (path is null) throw new PluginException("Plugin instance is not available");
143 | var dirName = Path.GetDirectoryName(path);
144 | if (dirName is null) throw new PluginException("Could not get a config directory name");
145 | return dirName;
146 | }
147 |
148 | ///
149 | /// Update plugin configuration.
150 | ///
151 | /// New plugin configuration.
152 | public static void UpdateConfig(BasePluginConfiguration newConfiguration)
153 | {
154 | _thisInstance?.UpdateConfiguration(newConfiguration);
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/PluginService.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Managers;
2 | using Jellyfin.Plugin.ListenBrainz.Services;
3 | using MediaBrowser.Controller.Library;
4 | using MediaBrowser.Controller.Session;
5 | using Microsoft.Extensions.Hosting;
6 | using Microsoft.Extensions.Logging;
7 | using ClientUtils = Jellyfin.Plugin.ListenBrainz.Clients.Utils;
8 |
9 | namespace Jellyfin.Plugin.ListenBrainz;
10 |
11 | ///
12 | /// ListenBrainz plugin service for Jellyfin server.
13 | ///
14 | public sealed class PluginService : IHostedService, IDisposable
15 | {
16 | private readonly ILogger _logger;
17 | private readonly ISessionManager _sessionManager;
18 | private readonly IUserDataManager _userDataManager;
19 | private readonly PluginImplementation _plugin;
20 | private bool _isActive;
21 | private bool _isDisposed;
22 |
23 | ///
24 | /// Initializes a new instance of the class.
25 | ///
26 | /// Session manager.
27 | /// Logger factory.
28 | /// HTTP client factory.
29 | /// User data manager.
30 | /// Library manager.
31 | /// User manager.
32 | public PluginService(
33 | ISessionManager sessionManager,
34 | ILoggerFactory loggerFactory,
35 | IHttpClientFactory clientFactory,
36 | IUserDataManager userDataManager,
37 | ILibraryManager libraryManager,
38 | IUserManager userManager)
39 | {
40 | _logger = loggerFactory.CreateLogger();
41 | _sessionManager = sessionManager;
42 | _userDataManager = userDataManager;
43 | _isActive = false;
44 |
45 | var listenBrainzLogger = loggerFactory.CreateLogger(Plugin.LoggerCategory + ".ListenBrainzApi");
46 | var listenBrainzClient = ClientUtils.GetListenBrainzClient(listenBrainzLogger, clientFactory);
47 |
48 | var musicBrainzLogger = loggerFactory.CreateLogger(Plugin.LoggerCategory + ".MusicBrainzApi");
49 | var musicBrainzClient = ClientUtils.GetMusicBrainzClient(musicBrainzLogger, clientFactory);
50 |
51 | var backupLogger = loggerFactory.CreateLogger(Plugin.LoggerCategory + ".Backup");
52 | var backupManager = new BackupManager(backupLogger);
53 |
54 | var pluginConfigService = new DefaultPluginConfigService();
55 |
56 | var favoriteSyncLogger = loggerFactory.CreateLogger(Plugin.LoggerCategory + ".FavoriteSync");
57 | var favoriteSyncService = new DefaultFavoriteSyncService(
58 | favoriteSyncLogger,
59 | listenBrainzClient,
60 | musicBrainzClient,
61 | pluginConfigService,
62 | libraryManager,
63 | userManager,
64 | userDataManager);
65 |
66 | _plugin = new PluginImplementation(
67 | loggerFactory.CreateLogger(Plugin.LoggerCategory),
68 | listenBrainzClient,
69 | musicBrainzClient,
70 | userManager,
71 | libraryManager,
72 | backupManager,
73 | pluginConfigService,
74 | favoriteSyncService);
75 | }
76 |
77 | ///
78 | /// Finalizes an instance of the class.
79 | ///
80 | ~PluginService() => Dispose(false);
81 |
82 | ///
83 | public void Dispose()
84 | {
85 | Dispose(true);
86 | GC.SuppressFinalize(this);
87 | }
88 |
89 | ///
90 | /// Dispose managed and unmanaged (own) resources.
91 | ///
92 | /// Dispose managed resources.
93 | private void Dispose(bool disposing)
94 | {
95 | if (_isDisposed)
96 | {
97 | return;
98 | }
99 |
100 | if (disposing)
101 | {
102 | _plugin.Dispose();
103 | }
104 |
105 | _isDisposed = true;
106 | }
107 |
108 | ///
109 | public Task StartAsync(CancellationToken cancellationToken)
110 | {
111 | _logger.LogInformation("Activating plugin service");
112 | if (_isActive)
113 | {
114 | _logger.LogInformation("Plugin service has been already activated");
115 | return Task.CompletedTask;
116 | }
117 |
118 | _sessionManager.PlaybackStart += _plugin.OnPlaybackStart;
119 | _sessionManager.PlaybackStopped += _plugin.OnPlaybackStop;
120 | _userDataManager.UserDataSaved += _plugin.OnUserDataSave;
121 |
122 | _isActive = true;
123 | _logger.LogInformation("Plugin service in now active");
124 | return Task.CompletedTask;
125 | }
126 |
127 | ///
128 | public Task StopAsync(CancellationToken cancellationToken)
129 | {
130 | _logger.LogInformation("Deactivating plugin service");
131 | if (!_isActive)
132 | {
133 | _logger.LogInformation("Plugin service has been already deactivated");
134 | return Task.CompletedTask;
135 | }
136 |
137 | _sessionManager.PlaybackStart -= _plugin.OnPlaybackStart;
138 | _sessionManager.PlaybackStopped -= _plugin.OnPlaybackStop;
139 | _userDataManager.UserDataSaved -= _plugin.OnUserDataSave;
140 |
141 | _isActive = false;
142 | _logger.LogInformation("Plugin service has been deactivated");
143 | return Task.CompletedTask;
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Services/DefaultFavoriteSyncService.cs:
--------------------------------------------------------------------------------
1 | using Jellyfin.Plugin.ListenBrainz.Configuration;
2 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
3 | using MediaBrowser.Controller.Library;
4 | using Microsoft.Extensions.Logging;
5 |
6 | namespace Jellyfin.Plugin.ListenBrainz.Services;
7 |
8 | ///
9 | /// Default implementation of the IFavoriteSyncService.
10 | ///
11 | public class DefaultFavoriteSyncService : IFavoriteSyncService
12 | {
13 | private readonly ILogger _logger;
14 | private readonly IListenBrainzClient _listenBrainzClient;
15 | private readonly IMusicBrainzClient _musicBrainzClient;
16 | private readonly IPluginConfigService _pluginConfigService;
17 | private readonly ILibraryManager _libraryManager;
18 | private readonly IUserManager _userManager;
19 | private readonly IUserDataManager _userDataManager;
20 |
21 | ///
22 | /// Initializes a new instance of the class.
23 | ///
24 | /// Logger.
25 | /// ListenBrainz client.
26 | /// MusicBrainz client.
27 | /// Plugin config service.
28 | /// Library manager.
29 | /// User manager.
30 | /// User data manager.
31 | public DefaultFavoriteSyncService(
32 | ILogger logger,
33 | IListenBrainzClient listenBrainzClient,
34 | IMusicBrainzClient musicBrainzClient,
35 | IPluginConfigService pluginConfigService,
36 | ILibraryManager libraryManager,
37 | IUserManager userManager,
38 | IUserDataManager userDataManager)
39 | {
40 | _logger = logger;
41 | _listenBrainzClient = listenBrainzClient;
42 | _musicBrainzClient = musicBrainzClient;
43 | _pluginConfigService = pluginConfigService;
44 | _libraryManager = libraryManager;
45 | _userManager = userManager;
46 | _userDataManager = userDataManager;
47 | IsEnabled = true;
48 | Instance = this;
49 | }
50 |
51 | ///
52 | public bool IsEnabled { get; private set; }
53 |
54 | ///
55 | public bool IsDisabled => !IsEnabled;
56 |
57 | ///
58 | /// Gets a singleton instance of the favorite sync service.
59 | ///
60 | public static IFavoriteSyncService? Instance { get; private set; }
61 |
62 | ///
63 | public void SyncToListenBrainz(Guid itemId, Guid jellyfinUserId, long? listenTs = null)
64 | {
65 | if (!IsEnabled)
66 | {
67 | _logger.LogDebug("Favorite sync service is disabled, cancelling sync");
68 | return;
69 | }
70 |
71 | var item = _libraryManager.GetItemById(itemId);
72 | if (item is null)
73 | {
74 | _logger.LogWarning("Item with ID {ItemId} not found", itemId);
75 | return;
76 | }
77 |
78 | var userConfig = _pluginConfigService.GetUserConfig(jellyfinUserId);
79 | if (userConfig is null)
80 | {
81 | _logger.LogWarning("ListenBrainz config for user ID {UserId} not found", jellyfinUserId);
82 | return;
83 | }
84 |
85 | var jellyfinUser = _userManager.GetUserById(jellyfinUserId);
86 | if (jellyfinUser is null)
87 | {
88 | _logger.LogWarning("User with ID {UserId} not found", jellyfinUserId);
89 | return;
90 | }
91 |
92 | var userItemData = _userDataManager.GetUserData(jellyfinUser, item);
93 |
94 | var recordingMbid = item.ProviderIds.GetValueOrDefault("MusicBrainzRecording");
95 | if (string.IsNullOrEmpty(recordingMbid) && _pluginConfigService.IsMusicBrainzEnabled)
96 | {
97 | try
98 | {
99 | var metadata = _musicBrainzClient.GetAudioItemMetadata(item);
100 | recordingMbid = metadata.RecordingMbid;
101 | }
102 | catch (Exception e)
103 | {
104 | _logger.LogDebug(e, "Failed to get recording MBID");
105 | }
106 | }
107 |
108 | string? recordingMsid = null;
109 | if (string.IsNullOrEmpty(recordingMbid))
110 | {
111 | if (listenTs is null)
112 | {
113 | _logger.LogInformation("No recording MBID is available, cannot sync favorite");
114 | return;
115 | }
116 |
117 | _logger.LogInformation("Recording MBID not found, trying to get recording MSID for the sync");
118 | recordingMsid = GetRecordingMsid(userConfig, listenTs.Value);
119 | }
120 |
121 | try
122 | {
123 | _logger.LogInformation("Attempting to sync favorite status");
124 | _listenBrainzClient.SendFeedback(userConfig, userItemData.IsFavorite, recordingMbid, recordingMsid);
125 | _logger.LogInformation("Favorite sync has been successful");
126 | }
127 | catch (Exception e)
128 | {
129 | _logger.LogInformation("Favorite sync failed: {Reason}", e.Message);
130 | _logger.LogDebug(e, "Favorite sync failed");
131 | }
132 | }
133 |
134 | ///
135 | public void Enable()
136 | {
137 | IsEnabled = true;
138 | _logger.LogDebug("Favorite sync service has been enabled");
139 | }
140 |
141 | ///
142 | public void Disable()
143 | {
144 | IsEnabled = false;
145 | _logger.LogDebug("Favorite sync service has been disabled");
146 | }
147 |
148 | private string? GetRecordingMsid(UserConfig userConfig, long listenTs)
149 | {
150 | const int MaxAttempts = 4;
151 | const int BackOffSecs = 5;
152 | var sleepSecs = 1;
153 |
154 | // Delay to maximize the chance of getting it on first try
155 | Thread.Sleep(500);
156 | for (int i = 0; i < MaxAttempts; i++)
157 | {
158 | _logger.LogDebug("Attempt number {Attempt} to get recording MSID", i + 1);
159 | try
160 | {
161 | _logger.LogInformation("Attempting to get recording MSID");
162 | return _listenBrainzClient.GetRecordingMsidByListenTs(userConfig, listenTs);
163 | }
164 | catch (Exception e)
165 | {
166 | _logger.LogInformation("Failed to get recording MSID: {Reason}", e.Message);
167 | _logger.LogDebug(e, "Failed to get recording MSID");
168 | }
169 |
170 | sleepSecs *= BackOffSecs;
171 | sleepSecs += new Random().Next(20);
172 | _logger.LogDebug(
173 | "Recording MSID with listen timestamp {Ts} not found, will retry in {Secs} seconds",
174 | listenTs,
175 | sleepSecs);
176 |
177 | Thread.Sleep(sleepSecs * 1000);
178 | }
179 |
180 | return null;
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/src/Jellyfin.Plugin.ListenBrainz/Services/DefaultPluginConfigService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.ObjectModel;
2 | using Jellyfin.Plugin.ListenBrainz.Configuration;
3 | using Jellyfin.Plugin.ListenBrainz.Interfaces;
4 |
5 | namespace Jellyfin.Plugin.ListenBrainz.Services;
6 |
7 | ///
8 | /// Default implementation of a PluginConfig service.
9 | ///
10 | public class DefaultPluginConfigService : IPluginConfigService
11 | {
12 | private static PluginConfiguration Config => Plugin.GetConfiguration();
13 |
14 | ///
15 | public bool IsAlternativeModeEnabled
16 | {
17 | get => Config.IsAlternativeModeEnabled;
18 | }
19 |
20 | ///
21 | public string ListenBrainzApiUrl
22 | {
23 | get => Config.ListenBrainzApiUrl;
24 | }
25 |
26 | ///
27 | public bool IsBackupEnabled
28 | {
29 | get => Config.IsBackupEnabled;
30 | }
31 |
32 | ///
33 | public bool IsMusicBrainzEnabled
34 | {
35 | get => Config.IsMusicBrainzEnabled;
36 | }
37 |
38 | ///
39 | public bool IsImmediateFavoriteSyncEnabled
40 | {
41 | get => Config.IsImmediateFavoriteSyncEnabled;
42 | }
43 |
44 | ///
45 | public Collection LibraryConfigs
46 | {
47 | get => Config.LibraryConfigs;
48 | }
49 |
50 | ///
51 | public Collection UserConfigs
52 | {
53 | get => Config.UserConfigs;
54 | }
55 |
56 | ///
57 | public UserConfig? GetUserConfig(Guid jellyfinUserId)
58 | {
59 | var userConfig = Config
60 | .UserConfigs
61 | .FirstOrDefault(u => u.JellyfinUserId == jellyfinUserId);
62 |
63 | return userConfig;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/Jellyfin.Plugin.ListenBrainz.Api.Tests/BaseApiClientTests.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using System.Net.Http;
3 | using System.Threading;
4 | using System.Threading.Tasks;
5 | using Jellyfin.Plugin.ListenBrainz.Api.Exceptions;
6 | using Jellyfin.Plugin.ListenBrainz.Api.Interfaces;
7 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
8 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Responses;
9 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
10 | using Jellyfin.Plugin.ListenBrainz.Http.Interfaces;
11 | using Microsoft.Extensions.Logging;
12 | using Moq;
13 | using Xunit;
14 |
15 | namespace Jellyfin.Plugin.ListenBrainz.Api.Tests;
16 |
17 | public class BaseApiClientTests
18 | {
19 | [Fact]
20 | public async Task BaseApiClient_RateLimitHandling()
21 | {
22 | var mockClient = new Mock();
23 | mockClient
24 | .Setup(c => c.SendRequest(It.IsAny(), It.IsAny()))
25 | .ReturnsAsync(new HttpResponseMessage
26 | {
27 | StatusCode = HttpStatusCode.TooManyRequests,
28 | Headers = { { Headers.RateLimitResetIn, "1" } }
29 | });
30 |
31 | var logger = new Mock();
32 | var sleepService = new Mock();
33 | var client = new BaseApiClient(mockClient.Object, logger.Object, sleepService.Object);
34 | var request = new ValidateTokenRequest("foobar");
35 |
36 | var action = async () => await client.SendPostRequest(request, CancellationToken.None);
37 | var ex = await Record.ExceptionAsync(action);
38 |
39 | Assert.IsType(ex);
40 | Assert.Contains("rate limit window", ex.Message);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/tests/Jellyfin.Plugin.ListenBrainz.Api.Tests/Jellyfin.Plugin.ListenBrainz.Api.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 |
7 | false
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | runtime; build; native; contentfiles; analyzers; buildtransitive
16 | all
17 |
18 |
19 | runtime; build; native; contentfiles; analyzers; buildtransitive
20 | all
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/tests/Jellyfin.Plugin.ListenBrainz.Api.Tests/JsonEncodeTests.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Collections.ObjectModel;
3 | using Jellyfin.Plugin.ListenBrainz.Api.Models;
4 | using Jellyfin.Plugin.ListenBrainz.Api.Models.Requests;
5 | using Jellyfin.Plugin.ListenBrainz.Api.Resources;
6 | using Newtonsoft.Json;
7 | using Xunit;
8 | using static Jellyfin.Plugin.ListenBrainz.Api.Resources.FeedbackScore;
9 |
10 | namespace Jellyfin.Plugin.ListenBrainz.Api.Tests;
11 |
12 | public class ListenTests
13 | {
14 | private readonly Listen _exampleListen = new("Foo Bar", "Foo - Bar")
15 | {
16 | ListenedAt = 1234,
17 | TrackMetadata = new TrackMetadata("Foo Bar", "Foo - Bar")
18 | {
19 | ReleaseName = "Foobar",
20 | AdditionalInfo = new AdditionalInfo
21 | {
22 | ReleaseMbid = "release-foo",
23 | ArtistMbids = new Collection { "artist-foo" },
24 | RecordingMbid = "recording-foo"
25 | }
26 | }
27 | };
28 |
29 | [Fact]
30 | public void Listen_Encode()
31 | {
32 | var listenJson = JsonConvert.SerializeObject(_exampleListen, BaseApiClient.SerializerSettings);
33 | Assert.NotNull(listenJson);
34 |
35 | const string ExpectedJson = """{"listened_at":1234,"track_metadata":{"artist_name":"Foo Bar","track_name":"Foo - Bar","release_name":"Foobar","additional_info":{"artist_mbids":["artist-foo"],"release_mbid":"release-foo","recording_mbid":"recording-foo"}}}""";
36 | Assert.Equal(ExpectedJson, listenJson);
37 | }
38 |
39 | [Fact]
40 | public void Listen_EncodeAndDecode()
41 | {
42 | var listenJson = JsonConvert.SerializeObject(_exampleListen, BaseApiClient.SerializerSettings);
43 | var deserializedListen = JsonConvert.DeserializeObject(listenJson, BaseApiClient.SerializerSettings);
44 | Assert.NotNull(deserializedListen);
45 | }
46 | }
47 |
48 | public class RecordingFeedbackTests
49 | {
50 | public static IEnumerable