Compare commits

...

136 Commits

Author SHA1 Message Date
felixfoertsch b5cd185b57 use gitea token for release workflow
TubeArchivistMetadata / Build (push) Successful in 44s
TubeArchivistMetadata / Release (push) Has been skipped
2026-07-01 10:27:22 +02:00
felixfoertsch 945f1ed6dd remove unsupported artifact upload from gitea workflow
TubeArchivistMetadata / Build (push) Successful in 44s
TubeArchivistMetadata / Release (push) Has been skipped
2026-07-01 10:21:07 +02:00
felixfoertsch 9433aff28a adapt release workflow for gitea actions
TubeArchivistMetadata / Build (push) Failing after 1m2s
TubeArchivistMetadata / Release (push) Has been skipped
2026-07-01 10:18:45 +02:00
felixfoertsch 754d4abd45 release tubearchivistmetadata 1.4.5.0
TubeArchivistMetadata Release / Build & Release (push) Failing after 1m34s
2026-07-01 09:58:49 +02:00
felixfoertsch 5f4afcc28e scope tubearchivist providers, fix sync intervals 2026-07-01 08:12:39 +02:00
DarkFighterLuke 42c02b33eb Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-12-02 13:40:50 +00:00
DarkFighterLuke 13553ef1c6 Merge pull request #77 from DerSeegler/master
Add error handling to SetProgress calls
2025-12-02 14:37:59 +01:00
DarkFighterLuke ac2e4dac9c Merge branch 'master' into master 2025-12-02 14:37:51 +01:00
REPLYNET\l.consoli 0bbb3c36cd Add Playlist ToString method 2025-12-02 14:27:19 +01:00
REPLYNET\l.consoli db0d9b115c Update version 2025-12-02 12:37:15 +01:00
REPLYNET\l.consoli bc343dd0c8 Edit SanitizeUrl to handle also query parameters
modified:   Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinPlaylistsSyncTask.cs
	modified:   Jellyfin.Plugin.TubeArchivistMetadata/TubeArchivist/TubeArchivistApi.cs
	modified:   Jellyfin.Plugin.TubeArchivistMetadata/Utils/Utils.cs
2025-12-02 12:31:44 +01:00
Yannik S. 9ed4869ece Add error handling to SetProgress calls 2025-11-28 23:59:02 +01:00
REPLYNET\l.consoli c2eebbe1a9 Handle TA playlists pagination 2025-11-18 15:52:13 +01:00
DarkFighterLuke e198b0fb92 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-11-13 19:31:52 +00:00
DarkFighterLuke f6f6bbf978 Merge pull request #73 from Jurrer/fix-on-watched-status-changed
fix condition for onWatchedStatusChanged
2025-11-13 20:27:36 +01:00
DarkFighterLuke 75c2fce017 Merge branch 'master' into fix-on-watched-status-changed 2025-11-13 20:27:29 +01:00
DarkFighterLuke 3c08e3c70c Merge pull request #72 from Jurrer/check-TA-video-status-before-syncing-to-TA
Check TA watched status before syncing to TA
2025-11-13 20:27:09 +01:00
DarkFighterLuke d5c706b833 Merge branch 'master' into check-TA-video-status-before-syncing-to-TA 2025-11-13 20:26:59 +01:00
DarkFighterLuke ad4fb92577 Merge pull request #71 from Jurrer/fix-watch-status-sync-to-TA-itemdata
fix video watch status sync to TA IsPlayed method
2025-11-13 20:26:44 +01:00
DarkFighterLuke 8aec7f804d Merge branch 'master' into fix-watch-status-sync-to-TA-itemdata 2025-11-13 20:26:29 +01:00
Jurrer 4c3de5146b collection name + user name check to OnWatchedStatusChange 2025-11-13 00:55:18 +01:00
Jurrer 1ce6587078 fix condition for onWatchedStatusChanged 2025-11-10 00:39:47 +01:00
Jurrer 7942e89341 check TA video status before syncing to TA 2025-11-09 05:33:05 +01:00
Jurrer 764dc8f326 fix video watch status sync to TA IsPlayed method 2025-11-09 03:17:39 +01:00
DarkFighterLuke af8a02d289 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-23 20:27:36 +00:00
DarkFighterLuke 9d745b0bc5 Use string.Empty 2025-10-23 22:26:39 +02:00
DarkFighterLuke a8e0896284 Bump version 2025-10-23 22:24:00 +02:00
DarkFighterLuke 53d9a17c2a Merge pull request #51 from wolffshots/master
Episodes by YYYYMMDD and some other cleanup
2025-10-23 22:21:19 +02:00
DarkFighterLuke 0c8bc243c6 Update Utils.cs 2025-10-23 22:19:22 +02:00
wolffshots 03ea6b9e5c chore: move episode naming to general options 2025-10-23 15:38:18 +02:00
wolffshots 6c9a824546 chore: merge remote-tracking branch 'parent/master' 2025-10-23 15:22:50 +02:00
DarkFighterLuke 5dc99e2fcf Update tasks intervals section in README
Added note about resource consumption with lower intervals.
2025-10-22 17:28:39 +02:00
DarkFighterLuke 478537bf0a Revise synchronization and add playlists features
Updated synchronization sections and added playlists synchronization details.
2025-10-22 17:13:02 +02:00
wolffshots 184c53ba00 chore: remove missed template entrie 2025-10-22 11:47:13 +02:00
wolffshots 70f063d33b Merge pull request #4 from wolffshots/jellyfin-10.11-merge
Merge commits from the upstream project to get the plugin working properly on Jellyfin 10.11
2025-10-22 11:43:22 +02:00
wolffshots 60753cb828 chore: merge upstream master and fix conflicts 2025-10-22 11:35:12 +02:00
DarkFighterLuke cba12adaa4 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-22 07:00:32 +00:00
DarkFighterLuke d94fd1bac3 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-22 06:57:57 +00:00
DarkFighterLuke dfdfac6be7 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-21 21:54:14 +00:00
DarkFighterLuke 806efa9dac Merge pull request #68 from tubearchivist/feature/playlists-sync
Playlists Sync
2025-10-21 23:53:02 +02:00
DarkFighterLuke 4ddb5cedac Update version and changelog 2025-10-21 23:40:50 +02:00
DarkFighterLuke cfa78f2a89 Merge branch 'jellyfin-10.11' into feature/playlists-sync 2025-10-21 23:36:57 +02:00
DarkFighterLuke 54cb513ae8 Update .NET version in GH actions 2025-10-21 23:36:43 +02:00
DarkFighterLuke bee5538d19 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-21 21:34:54 +00:00
DarkFighterLuke 40194f9f26 Update .NET version in GH actions 2025-10-21 23:32:05 +02:00
DarkFighterLuke 67c6fd0422 Merge branch 'jellyfin-10.11' 2025-10-21 23:28:28 +02:00
DarkFighterLuke 7aa6af30fa Merge branch 'jellyfin-10.11' into feature/playlists-sync 2025-10-21 23:26:09 +02:00
DarkFighterLuke 7e496643f8 Adapt calls to IsPlayed method new signature
modified:   Jellyfin.Plugin.TubeArchivistMetadata/Jellyfin.Plugin.TubeArchivistMetadata.csproj
	modified:   Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
	modified:   Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs
2025-10-21 23:16:50 +02:00
DarkFighterLuke 6e7dbd859c Revise installation steps and .NET version in README
Updated installation instructions and image links in README.
2025-10-21 23:14:29 +02:00
DarkFighterLuke 13c4144aab Remove version 1.4.0.0 from manifest.json
Removed version 1.4.0.0 from the versions list in manifest.json.
2025-10-20 23:49:55 +02:00
DarkFighterLuke 98f73185d1 Merge pull request #67 from tubearchivist/jellyfin-10.11
Update plugin for Jellyfin 10.11
2025-10-20 22:29:22 +02:00
DarkFighterLuke f7cbff910d Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-10-20 20:28:12 +00:00
DarkFighterLuke 2d5327d31b Bump version 2025-10-20 22:27:01 +02:00
DarkFighterLuke a2e29dee3b Merge branch 'master' into jellyfin-10.11 2025-10-20 22:14:56 +02:00
wolffshots cf87c6e829 Merge branch 'master' into master 2025-08-05 12:16:38 +02:00
wolffshots d7f4f5cbcc chore: explicitly handle null description 2025-08-05 10:37:54 +02:00
wolffshots 6e8b9ca744 doc: update README.md to reflect numbering options 2025-08-04 21:18:57 +02:00
wolffshots b0ca3a49a5 chore: catch errors on FormatDescription 2025-08-04 18:46:30 +02:00
wolffshots 4358072001 feat: add configuration for episode numbering scheme 2025-08-04 18:21:30 +02:00
DarkFighterLuke bdd99441d1 Fix #52 2025-07-16 22:58:44 +02:00
DarkFighterLuke 38ed5dd9e0 Merge branch 'master' into master 2025-07-13 15:42:09 +02:00
DarkFighterLuke cfd94260f1 Add same check on wached status event handler 2025-07-13 15:40:49 +02:00
DarkFighterLuke a28b4ccd7c Fix space after if statement and item id 2025-07-13 15:36:04 +02:00
DarkFighterLuke ff55b9f517 Update Plugin.cs 2025-07-13 14:49:41 +02:00
DarkFighterLuke 93a2de5d46 Merge branch 'master' into master 2025-07-13 13:26:17 +02:00
DarkFighterLuke 5b62e4c001 Update JFToTubeArchivistProgressSyncTask.cs 2025-07-13 13:22:18 +02:00
DarkFighterLuke 50c0e1cef2 Merge pull request #54 from hsvflight/patch-1
Update Plugin.cs - Guid empty
2025-07-13 11:43:26 +02:00
DarkFighterLuke ca0a9215fe Change log message
The event is not catastrophic in Jellyfin, so it can be hidden the user unless debug level logs are enabled
2025-07-13 11:39:32 +02:00
DarkFighterLuke 6679867f74 Update README.md 2025-07-13 11:35:37 +02:00
DarkFighterLuke 6926a44e7c Merge pull request #57 from Atinoda/master
Reconfigure logging to debug
2025-07-10 22:40:59 +02:00
DarkFighterLuke d386f79dfd Add configuration options 2025-07-10 22:32:22 +02:00
DarkFighterLuke 46dd4fcf5b Implement playlists deletion 2025-07-01 22:16:47 +02:00
Che Cameron ab783238c3 Reconfigure logging to debug 2025-07-01 20:06:27 +01:00
DarkFighterLuke ff99517d12 Format file 2025-06-23 14:59:49 +02:00
DarkFighterLuke 532438d110 Fix case 2025-06-23 14:59:49 +02:00
DarkFighterLuke 8e4194f56b Schedule playlists tasks 2025-06-23 14:59:49 +02:00
DarkFighterLuke bff699fc3f Implement JF-> TA playlist sync scheduled task 2025-06-23 14:59:49 +02:00
DarkFighterLuke f3a9050311 Implement TA playlists management API calls 2025-06-23 14:59:49 +02:00
DarkFighterLuke 3cb340c61a Create entities for TA playlists creation and edit 2025-06-23 14:59:49 +02:00
DarkFighterLuke 4317fdbf63 Fix log message and comment 2025-06-23 14:59:49 +02:00
DarkFighterLuke a9c6611dcf Implement TA-> JF playlist sync scheduled task 2025-06-23 14:59:49 +02:00
DarkFighterLuke e28a3ea37b Implement TA playlists API call 2025-06-23 14:59:49 +02:00
DarkFighterLuke 86dd1769ec Create entities to TA playlists 2025-06-23 14:59:48 +02:00
DarkFighterLuke 4711a6a742 Update plugin for Jellyfin 10.11 2025-06-21 12:56:37 +02:00
hsvflight a783b3c739 Update Plugin.cs - Guid empty
Getting this error:
[2025-06-16 00:41:58.499 +00:00] [FTL] Unhandled Exception  
System.ArgumentException: Guid can't be empty (Parameter 'id')  
at Jellyfin.Plugin.TubeArchivistMetadata.Plugin.OnPlaybackProgress(...)

And right after playback fails
[2025-06-16 00:42:23.345 +00:00] [INF] Playback stopped reported by app "Jellyfin Roku" "3.0.5" playing "Video". Stopped at "973000" ms
2025-06-15 21:33:33 -05:00
wolffshots 3e95ca26aa chore: make sync task logs debugs 2025-05-29 11:23:44 +02:00
wolffshots a58139eca0 chore: remove duplicated efforts 2025-05-29 11:19:27 +02:00
wolffshots 5ab5224328 chore: ran dotnet format on Plugin.cs 2025-05-29 11:00:44 +02:00
wolffshots d36d677179 chore: remove unused import 2025-05-29 10:57:02 +02:00
wolffshots 784592b362 chore: remove debugging log statements 2025-05-29 10:52:07 +02:00
jadon 2f90d6a03e chore: remove extra season and move into if 2025-05-28 23:32:06 +02:00
jadon af375987ee chore: check library before updating watch status 2025-05-28 23:29:33 +02:00
jadon ee683d5737 feat: set e# and logging 2025-05-28 23:25:17 +02:00
DarkFighterLuke 583e94b35a Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-05-27 18:36:58 +00:00
DarkFighterLuke 80a1b6050d Merge pull request #50 from tubearchivist/bugfix/sync-usernames
Update build.yml
2025-05-27 20:34:48 +02:00
DarkFighterLuke a4942e78b6 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-05-26 21:43:57 +00:00
DarkFighterLuke 48070f311e Merge pull request #49 from tubearchivist/bugfix/sync-usernames
JF->TA Sync fix
2025-05-26 23:43:57 +02:00
DarkFighterLuke 96a72ab909 Update build.yml 2025-05-26 23:41:43 +02:00
DarkFighterLuke 9c6aa56db0 Fix JFToTubearchivistProgressSyncTask 2025-05-26 23:38:04 +02:00
DarkFighterLuke 413f45046d Sync progress status in OnWatchedStatusChange when watched is false
modified:   Jellyfin.Plugin.TubeArchivistMetadata/Plugin.cs
2025-05-26 23:37:20 +02:00
DarkFighterLuke 5ad6b2a381 Fix OnPlaybackProgress collection name retrieval 2025-05-26 23:35:42 +02:00
DarkFighterLuke 795047a50e Update README.md 2025-03-13 23:17:08 +01:00
DarkFighterLuke 323a208de2 Update README.md 2025-03-13 23:16:26 +01:00
DarkFighterLuke 55d320c456 Update BUG-REPORT.yml 2025-03-13 23:10:01 +01:00
DarkFighterLuke d5f2f0ae8e Update BUG-REPORT.yml 2025-03-13 23:08:04 +01:00
DarkFighterLuke c3bdfc26ea Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-03-10 22:01:17 +00:00
DarkFighterLuke c6f7f79385 Merge branch 'master' of github.com:tubearchivist/tubearchivist-jf-plugin 2025-03-10 23:00:05 +01:00
DarkFighterLuke 53ede13f54 Update build.yml 2025-03-10 22:59:59 +01:00
DarkFighterLuke eeda7dd91e Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-03-10 21:55:37 +00:00
DarkFighterLuke 867fbbae5b Merge pull request #38 from mattkduran/master
Added new method for auth headers
2025-03-10 22:46:11 +01:00
DarkFighterLuke 76ac750de2 Fix UpdateAuthorizationHeader method 2025-03-10 22:44:37 +01:00
DarkFighterLuke ddbbb4ba25 Merge branch 'master' into master 2025-03-10 19:01:34 +01:00
DarkFighterLuke 3fca2d1e09 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2025-03-10 17:47:46 +00:00
DarkFighterLuke 4f2f6c0429 Merge pull request #41 from tubearchivist/ta-v0.5.0
TubeArchivist v0.5.0 new API
2025-03-10 18:45:14 +01:00
Matt Duran ebb2cdfcc6 Modified setter to allow for usernames with spaces 2025-03-06 20:58:19 -08:00
Matt Duran c058a35abe Added new method for auth headers 2025-03-03 09:48:10 -08:00
DarkFighterLuke 11eb456c56 Fix JF crash when setting watched status with TA offline 2025-02-22 15:39:58 +01:00
DarkFighterLuke 4db1ce755a Update build.yaml 2025-02-22 15:15:54 +01:00
DarkFighterLuke a424721d93 Update README.md 2025-02-22 15:14:11 +01:00
DarkFighterLuke 16598bb86e Adapt to the new TA API changes 2025-02-21 17:42:58 +01:00
DarkFighterLuke 98472b5aa6 Fix JF->TA watched status synchronization 2025-02-21 17:41:51 +01:00
DarkFighterLuke ab96b227a1 Merge pull request #31 from AgentK20/tag-nre-fix
Fix ArgumentNullException when channel has no tags
2025-01-08 23:12:25 +01:00
AgentK 5ff96113c8 Spacing 2024-12-01 11:12:34 -06:00
AgentK 6697f2b4cc Correctly handle the possibility of a null-deserialized tags 2024-12-01 11:09:53 -06:00
DarkFighterLuke a45ef87f84 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2024-11-04 21:30:26 +00:00
DarkFighterLuke 157facdceb Merge workaround for JF crashing when scanning Series pull request by wolffshots 2024-11-04 22:29:26 +01:00
DarkFighterLuke be8481c795 Remove useless comments 2024-11-04 22:26:42 +01:00
DarkFighterLuke bd0a8371a5 Merge pull request #26 from wolffshots/workaround-series-crash
Workaround for JF crashing when scanning Series
2024-11-04 22:23:45 +01:00
DarkFighterLuke a75c474647 Fix manifest.json 2024-11-04 21:52:19 +01:00
DarkFighterLuke 1dd7019676 Fix manifest.json 2024-11-04 21:47:55 +01:00
DarkFighterLuke 355576c283 Update README.md 2024-11-04 21:44:38 +01:00
DarkFighterLuke 9091cdbe5b Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2024-11-04 20:41:49 +00:00
DarkFighterLuke a162ebb25d Add compatibility with Jellyfin 10.10 2024-11-04 21:40:29 +01:00
DarkFighterLuke 36eec27d9f Update .gitignore 2024-11-04 21:39:19 +01:00
wolffshots 2fee654efe chore: wrap itemYTId stuff in try catch 2024-10-22 15:11:50 +02:00
DarkFighterLuke ce8aea629b Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2024-09-14 13:42:01 +00:00
34 changed files with 2218 additions and 410 deletions
+152
View File
@@ -0,0 +1,152 @@
name: TubeArchivistMetadata
permissions:
code: read
releases: write
on:
push:
branches:
- main
tags:
- v*
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
name: Build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore
run: dotnet restore TubeArchivistMetadata.sln
- name: Build
run: dotnet build TubeArchivistMetadata.sln -c Release --no-restore
- name: Package plugin
run: |
set -euo pipefail
plugin_version="$(awk -F'\"' '/^version:/ { print $2 }' build.yaml)"
artifact="artifacts/tubearchivistmetadata_${plugin_version}.zip"
checksum="$(./scripts/package-release.sh)"
echo "PLUGIN_VERSION=$plugin_version" >> "$GITHUB_ENV"
echo "PLUGIN_ARTIFACT=$artifact" >> "$GITHUB_ENV"
echo "PLUGIN_CHECKSUM=$checksum" >> "$GITHUB_ENV"
- name: Validate release metadata
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tag_version="${GITHUB_REF_NAME#v}"
if [ "$PLUGIN_VERSION" != "$tag_version" ] && [ "$PLUGIN_VERSION" != "${tag_version}.0" ]; then
echo "Tag $GITHUB_REF_NAME does not match build.yaml version $PLUGIN_VERSION" >&2
exit 1
fi
grep -F "\"version\": \"$PLUGIN_VERSION\"" manifest.json >/dev/null
grep -F "\"checksum\": \"$PLUGIN_CHECKSUM\"" manifest.json >/dev/null
grep -F "/releases/download/$GITHUB_REF_NAME/$(basename "$PLUGIN_ARTIFACT")" manifest.json >/dev/null
release:
runs-on: ubuntu-latest
name: Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Setup Go for tea
uses: actions/setup-go@v5
with:
go-version: 1.26.x
- name: Install tea
run: go install code.gitea.io/tea@v0.14.0
- name: Restore
run: dotnet restore TubeArchivistMetadata.sln
- name: Build
run: dotnet build TubeArchivistMetadata.sln -c Release --no-restore
- name: Package plugin
run: |
set -euo pipefail
plugin_version="$(awk -F'\"' '/^version:/ { print $2 }' build.yaml)"
artifact="artifacts/tubearchivistmetadata_${plugin_version}.zip"
checksum="$(./scripts/package-release.sh)"
echo "PLUGIN_VERSION=$plugin_version" >> "$GITHUB_ENV"
echo "PLUGIN_ARTIFACT=$artifact" >> "$GITHUB_ENV"
echo "PLUGIN_CHECKSUM=$checksum" >> "$GITHUB_ENV"
- name: Validate release metadata
run: |
set -euo pipefail
tag_version="${GITHUB_REF_NAME#v}"
if [ "$PLUGIN_VERSION" != "$tag_version" ] && [ "$PLUGIN_VERSION" != "${tag_version}.0" ]; then
echo "Tag $GITHUB_REF_NAME does not match build.yaml version $PLUGIN_VERSION" >&2
exit 1
fi
grep -F "\"version\": \"$PLUGIN_VERSION\"" manifest.json >/dev/null
grep -F "\"checksum\": \"$PLUGIN_CHECKSUM\"" manifest.json >/dev/null
grep -F "/releases/download/$GITHUB_REF_NAME/$(basename "$PLUGIN_ARTIFACT")" manifest.json >/dev/null
- name: Generate release notes
run: |
set -euo pipefail
changelog="$(awk '/changelog:/ {flag=1; next} /^[^ ]/ {flag=0} flag' build.yaml | awk '{$1=$1};1')"
{
echo "Changelog:"
echo "* $changelog"
} > release-changelog.md
- name: Create Gitea release
env:
GITEA_SERVER_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GITEA_SERVER_TOKEN:-}" ]; then
echo "Missing built-in GITEA_TOKEN" >&2
exit 1
fi
tea logins add \
--name gitea \
--url "$GITHUB_SERVER_URL" \
--token "$GITEA_SERVER_TOKEN" \
--no-version-check
tea releases create "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "TubeArchivist Metadata $PLUGIN_VERSION" \
--note-file release-changelog.md \
--asset "$PLUGIN_ARTIFACT" \
--login gitea
+4
View File
@@ -15,8 +15,12 @@ body:
options:
- label: I'm running the latest version of tubearchivist-jf-plugin.
required: true
- label: I'm running the latest version of TubeArchivist.
required: true
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section.
required: true
- label: I have searched for both closed and open already existing issues about the problem I am reporting.
required: true
- type: input
id: os
-86
View File
@@ -1,86 +0,0 @@
name: TubeArchivistMetadata Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
name: Build & Release
permissions:
contents: write
packages: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0
- name: Get release version
id: newtag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
- name: Setup .Net
uses: actions/setup-dotnet@v1
with:
dotnet-version: 8.0.x
- name: Add Jellyfin GitHub Nuget registry to sources
run: |
dotnet nuget add source \
--username DarkFighterLuke \
--password ${{ secrets.GITHUB_TOKEN }} \
--store-password-in-clear-text \
--name github https://nuget.pkg.github.com/jellyfin/index.json
- name: Get version number only
run: |
TAG=${{ steps.newtag.outputs.tag }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- name: Generate release message
run: |
# Read the YAML file and extract the value of the 'changelog' field
changelog=$(awk '/changelog:/ {flag=1; next} /^[^ ]/ {flag=0} flag' build.yaml | awk '{$1=$1};1')
# Check if changelog contains multiple lines
if [[ $(echo "$changelog" | wc -l) -gt 1 ]]; then
changelog=$(echo "$changelog" | sed 's/^/* /')
else
changelog="* $changelog"
fi
# Create release-changelog.md
echo "Changelog:" > release-changelog.md
echo "$changelog" >> release-changelog.md
- name: Build Jellyfin Plugin
uses: DarkFighterLuke/jellyfin-plugin-repository-manager@7a96768accc155ac7b597351b5033532f3c48173
id: jprm
with:
dotnet-target: net8.0
version: ${VERSION}
update-manifest: "true"
- name: Create release
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: ${{ steps.jprm.outputs.artifact }}
makeLatest: latest
bodyFile: release-changelog.md
tag: ${{ steps.newtag.outputs.tag }}
allowUpdates: true
- name: Commit manifest.json
uses: EndBug/add-and-commit@v9
with:
add: manifest.json
commit: --signoff
message: "Update manifest.json"
pull: "--no-rebase -X ours origin master"
+1
View File
@@ -3,3 +3,4 @@ obj/
.vs/
.idea/
artifacts
nuget.config
+6 -5
View File
@@ -1,15 +1,16 @@
{
// jellyfinDir : The directory of the cloned jellyfin server project
// This needs to be built once before it can be used
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
"jellyfinDir": "${workspaceFolder}/../jellyfin/Jellyfin.Server",
// jellyfinWebDir : The directory of the cloned jellyfin-web project
// This needs to be built once before it can be used
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
"jellyfinWebDir": "${workspaceFolder}/../jellyfin-web",
// jellyfinDataDir : the root data directory for a running jellyfin instance
// This is where jellyfin stores its configs, plugins, metadata etc
// This is platform specific by default, but on Windows defaults to
// ${env:LOCALAPPDATA}/jellyfin
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
"jellyfinDataDir": "${env:LOCALAPPDATA}/jellyfin",
// The name of the plugin
"pluginName" : "Jellyfin.Plugin.Template",
}
"pluginName": "Jellyfin.Plugin.Template",
"git.ignoreLimitWarning": true,
}
+4 -3
View File
@@ -1,7 +1,8 @@
<Project>
<PropertyGroup>
<Version>1.3.0.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
<Version>1.4.5.0</Version>
<AssemblyVersion>1.4.5.0</AssemblyVersion>
<FileVersion>1.4.5.0</FileVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
</Project>
@@ -0,0 +1,17 @@
namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
/// <summary>
/// The NumberingScheme.
/// </summary>
public enum NumberingScheme
{
/// <summary>
/// Default (no numbering).
/// </summary>
Default,
/// <summary>
/// YYYYMMDD (e.g. 20250804 for August 4th, 2025).
/// </summary>
YYYYMMDD,
}
@@ -36,11 +36,17 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
_tubeArchivistUrl = string.Empty;
_tubeArchivistApiKey = string.Empty;
MaxDescriptionLength = 500;
JFTASync = false;
JFTAProgressSync = false;
JFUsernameFrom = string.Empty;
TAJFSync = false;
TAJFProgressSync = false;
JFTAPlaylistsSync = false;
JFTAPlaylistsDelete = false;
TAJFPlaylistsSync = false;
TAJFPlaylistsDelete = false;
_jfUsernamesTo = new HashSet<string>();
TAJFTaskInterval = 1;
TAJFProgressTaskInterval = 60;
JFTAPlaylistsSyncTaskInterval = 60;
TAJFPlaylistsSyncTaskInterval = 60;
}
/// <summary>
@@ -88,6 +94,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
{
_tubeArchivistApiKey = value;
Plugin.Instance?.LogTAApiConnectionStatus();
Plugin.Instance?.UpdateAuthorizationHeader(value);
}
}
@@ -99,12 +106,32 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
/// <summary>
/// Gets or sets a value indicating whether to enable TA->JF playback progress synchronization.
/// </summary>
public bool TAJFSync { get; set; }
public bool TAJFProgressSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable JF->TA playback progress synchronization.
/// </summary>
public bool JFTASync { get; set; }
public bool JFTAProgressSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable JF->TA playlists synchronization.
/// </summary>
public bool JFTAPlaylistsSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to delete playlists from TA when not found on JF.
/// </summary>
public bool JFTAPlaylistsDelete { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to enable TA->JF playlists synchronization.
/// </summary>
public bool TAJFPlaylistsSync { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to delete playlists from JF when not found on TA.
/// </summary>
public bool TAJFPlaylistsDelete { get; set; }
/// <summary>
/// Gets or sets the playback progress owner Jellyfin username to synchronize data to TubeArchivist.
@@ -118,14 +145,26 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
{
get
{
_logger.LogDebug("JFUsernamesTo configured: {Message}", string.Join(", ", _jfUsernamesTo));
_logger.LogInformation("JFUsernamesTo configured: {Message}", string.Join(", ", _jfUsernamesTo));
return string.Join(", ", _jfUsernamesTo);
}
set
{
value.Replace(" ", string.Empty, StringComparison.CurrentCulture).Split(',').ToList().ForEach(u => _jfUsernamesTo.Add(u));
_logger.LogDebug("Set JFUsernamesTo to: {Message}", value);
// Clear existing usernames
_jfUsernamesTo.Clear();
// Split by comma, then trim each part to remove leading/trailing spaces
foreach (var username in value.Split(','))
{
var trimmedUsername = username.Trim();
if (!string.IsNullOrEmpty(trimmedUsername))
{
_jfUsernamesTo.Add(trimmedUsername);
}
}
_logger.LogInformation("Set JFUsernamesTo to: {Message}", string.Join(", ", _jfUsernamesTo));
}
}
@@ -133,7 +172,24 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
/// Gets or sets the interval in seconds at which the TubeArchivist to Jellyfin playback progress synchronization task should run.
/// It requires Jellyfin server restart to take effect.
/// </summary>
public int TAJFTaskInterval { get; set; }
public int TAJFProgressTaskInterval { get; set; }
/// <summary>
/// Gets or sets the interval in seconds at which the Jellyfin to TubeArchivist playlists synchronization task should run.
/// It requires Jellyfin server restart to take effect.
/// </summary>
public int JFTAPlaylistsSyncTaskInterval { get; set; }
/// <summary>
/// Gets or sets the interval in seconds at which the TubeArchivist to Jellyfin playlists synchronization task should run.
/// It requires Jellyfin server restart to take effect.
/// </summary>
public int TAJFPlaylistsSyncTaskInterval { get; set; }
/// <summary>
/// Gets or sets the preferred numbering scheme for episodes (index number) in Jellyfin.
/// </summary>
public NumberingScheme EpisodeNumberingScheme { get; set; } = NumberingScheme.Default;
/// <summary>
/// Gets the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist.
@@ -41,29 +41,56 @@
<div class="fieldDescription">This is the maximum length of the descriptions showed in series
and episodes</div>
</div>
<div>
<h2>Playback synchronization</h2>
<div class="selectContainer">
<label class="selectLabel" for="EpisodeNumberingScheme">Episode numbering scheme</label>
<select is="emby-select" id="EpisodeNumberingScheme" name="EpisodeNumberingScheme" label="Description markup" class="emby-select-withcolor emby-select">
<option value="Default">Default (no specified numbering)</option>
<option value="YYYYMMDD">YYYYMMDD (e.g. 20250804 for August 4th, 2025)</option>
</select>
<div class="selectArrowContainer">
<span class="selectArrow material-icons keyboard_arrow_down" aria-hidden="true"></span>
</div>
<div class="fieldDescription">This is the numbering scheme for episodes in Jellyfin (IndexNumber).
Default is the same as no numbering which allows Jellyfin to use the fallback numbering scheme.
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTASync">
<input id="JFTASync" name="JFTASync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playback progress to TubeArchivist</span>
</label>
<div>
<h2>Synchronization</h2>
</div>
<div>
<h3>Jellyfin to TubeArchivist</h3>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFUsernameFrom">Jellyfin username to sync
playback status from</label>
from</label>
<input id="JFUsernameFrom" name="JFUsernameFrom" type="text" is="emby-input"
placeholder="Username1, username2, ..." />
<div class="fieldDescription">This is the Jellyfin username to synchronize data from on
placeholder="Username" />
<div class="fieldDescription">This is the Jellyfin username to synchronize data to
TubeArchivist</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFSync">
<input id="TAJFSync" name="TAJFSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playback progress to Jellyfin</span>
<label class="emby-checkbox-label" for="JFTAProgressSync">
<input id="JFTAProgressSync" name="JFTAProgressSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playback progress to TubeArchivist</span>
</label>
</div>
<div style="display: flex; gap: 2em;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTAPlaylistsSync">
<input id="JFTAPlaylistsSync" name="JFTAPlaylistsSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize Jellyfin playlists to TubeArchivist</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="JFTAPlaylistsDelete">
<input id="JFTAPlaylistsDelete" name="JFTAPlaylistsDelete" type="checkbox" is="emby-checkbox" />
<span>Delete TubeArchivist playlists when no more present on Jellyfin</span>
</label>
</div>
</div>
<div>
<h3>TubeArchivist to Jellyfin</h3>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFUsernamesTo">Jellyfin usernames to sync
playback status to</label>
@@ -72,13 +99,52 @@
<div class="fieldDescription">These are the (comma separated) Jellyfin usernames to synchronize
data from TubeArchivist</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFProgressSync">
<input id="TAJFProgressSync" name="TAJFProgressSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playback progress to Jellyfin</span>
</label>
</div>
<div style="display: flex; gap: 2em;">
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFPlaylistsSync">
<input id="TAJFPlaylistsSync" name="TAJFPlaylistsSync" type="checkbox" is="emby-checkbox" />
<span>Synchronize TubeArchivist playlists to Jellyfin</span>
</label>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label" for="TAJFPlaylistsDelete">
<input id="TAJFPlaylistsDelete" name="TAJFPlaylistsDelete" type="checkbox" is="emby-checkbox" />
<span>Delete Jellyfin playlists when no more present on TubeArchivist</span>
</label>
</div>
</div>
<div>
<h2>Tasks intervals</h2>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TAJFTaskInterval">TubeArchivist to Jellyfin
<label class="inputLabel inputLabelUnfocused" for="TAJFProgressTaskInterval">TubeArchivist to Jellyfin
playback progress synchronization interval</label>
<input id="TAJFTaskInterval" name="TAJFTaskInterval" type="number" is="emby-input" min="1" />
<input id="TAJFProgressTaskInterval" name="TAJFProgressTaskInterval" type="number" is="emby-input" min="60" />
<div class="fieldDescription">This is the TubeArchivist to Jellyfin playback progress
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="JFTAPlaylistsSyncTaskInterval">Jellyfin to TubeArchivist
playlists synchronization interval</label>
<input id="JFTAPlaylistsSyncTaskInterval" name="JFTAPlaylistsSyncTaskInterval" type="number" is="emby-input" min="60" />
<div class="fieldDescription">This is the Jellyfin to TubeArchivist playlists
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TAJFPlaylistsSyncTaskInterval">TubeArchivist to Jellyfin
playlists synchronization interval</label>
<input id="TAJFPlaylistsSyncTaskInterval" name="TAJFPlaylistsSyncTaskInterval" type="number" is="emby-input"
min="60" />
<div class="fieldDescription">This is the TubeArchivist to Jellyfin playlists
synchronization interval in seconds (Jellyfin restart required).</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -100,11 +166,16 @@
document.querySelector('#TubeArchivistUrl').value = config.TubeArchivistUrl;
document.querySelector('#TubeArchivistApiKey').value = config.TubeArchivistApiKey;
document.querySelector('#MaxDescriptionLength').value = config.MaxDescriptionLength;
document.querySelector('#JFTASync').checked = config.JFTASync;
document.querySelector('#JFTAProgressSync').checked = config.JFTAProgressSync;
document.querySelector('#JFUsernamesTo').value = config.JFUsernamesTo;
document.querySelector('#TAJFSync').checked = config.TAJFSync;
document.querySelector('#TAJFProgressSync').checked = config.TAJFProgressSync;
document.querySelector('#JFTAPlaylistsSync').checked = config.JFTAPlaylistsSync;
document.querySelector('#TAJFPlaylistsSync').checked = config.TAJFPlaylistsSync;
document.querySelector('#JFUsernameFrom').value = config.JFUsernameFrom;
document.querySelector('#TAJFTaskInterval').value = config.TAJFTaskInterval;
document.querySelector('#EpisodeNumberingScheme').value = config.EpisodeNumberingScheme;
document.querySelector('#TAJFProgressTaskInterval').value = config.TAJFProgressTaskInterval;
document.querySelector('#JFTAPlaylistsSyncTaskInterval').value = config.JFTAPlaylistsSyncTaskInterval;
document.querySelector('#TAJFPlaylistsSyncTaskInterval').value = config.TAJFPlaylistsSyncTaskInterval;
Dashboard.hideLoadingMsg();
});
});
@@ -117,11 +188,16 @@
config.TubeArchivistUrl = document.querySelector('#TubeArchivistUrl').value;
config.TubeArchivistApiKey = document.querySelector('#TubeArchivistApiKey').value;
config.MaxDescriptionLength = document.querySelector('#MaxDescriptionLength').value;
config.JFTASync = document.querySelector('#JFTASync').checked;
config.JFTAProgressSync = document.querySelector('#JFTAProgressSync').checked;
config.JFUsernamesTo = document.querySelector('#JFUsernamesTo').value;
config.TAJFSync = document.querySelector('#TAJFSync').checked;
config.TAJFProgressSync = document.querySelector('#TAJFProgressSync').checked;
config.JFTAPlaylistsSync = document.querySelector('#JFTAPlaylistsSync').checked;
config.TAJFPlaylistsSync = document.querySelector('#TAJFPlaylistsSync').checked;
config.JFUsernameFrom = document.querySelector('#JFUsernameFrom').value;
config.TAJFTaskInterval = document.querySelector('#TAJFTaskInterval').value;
config.EpisodeNumberingScheme = document.querySelector('#EpisodeNumberingScheme').value;
config.TAJFProgressTaskInterval = document.querySelector('#TAJFProgressTaskInterval').value;
config.JFTAPlaylistsSyncTaskInterval = document.querySelector('#JFTAPlaylistsSyncTaskInterval').value;
config.TAJFPlaylistsSyncTaskInterval = document.querySelector('#TAJFPlaylistsSyncTaskInterval').value;
ApiClient.updatePluginConfiguration(TAMetadataConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.TubeArchivistMetadata</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.9.0-20240509061132" />
<PackageReference Include="Jellyfin.Model" Version="10.9.0-20240509061132" />
<PackageReference Include="Jellyfin.Controller" Version="10.11.0" />
<PackageReference Include="Jellyfin.Model" Version="10.11.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
+110 -41
View File
@@ -14,6 +14,7 @@ using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
@@ -28,6 +29,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@@ -37,18 +39,18 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="taskManager">Instance of the <see cref="ITaskManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
public Plugin(
IApplicationPaths applicationPaths,
IXmlSerializer xmlSerializer,
ILogger<Plugin> logger,
ISessionManager sessionManager,
ILibraryManager libraryManager,
ITaskManager taskManager,
IUserManager userManager,
IUserDataManager userDataManager)
IUserDataManager userDataManager,
IPlaylistManager playlistManager)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
@@ -57,31 +59,15 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
handler.AllowAutoRedirect = false;
handler.CheckCertificateRevocationList = true;
HttpClient = new HttpClient(handler);
HttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Token", Instance?.Configuration.TubeArchivistApiKey);
UpdateAuthorizationHeader(Configuration.TubeArchivistApiKey);
SessionManager = sessionManager;
sessionManager.PlaybackProgress += OnPlaybackProgress;
LibraryManager = libraryManager;
_userManager = userManager;
_userDataManager = userDataManager;
userDataManager.UserDataSaved += OnWatchedStatusChange;
var taToJellyfinProgressSyncTask = new TAToJellyfinProgressSyncTask(logger, libraryManager, userManager, userDataManager);
var jfToTubearchivistProgressSyncTask = new JFToTubearchivistProgressSyncTask(logger, libraryManager, userManager, userDataManager);
var isTAJFTaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(taToJellyfinProgressSyncTask.Name, StringComparison.Ordinal));
if (Instance!.Configuration.TAJFSync && !isTAJFTaskPresent)
{
logger.LogInformation("Queueing task {TaskName}.", taToJellyfinProgressSyncTask.Name);
taskManager.AddTasks([taToJellyfinProgressSyncTask]);
taskManager.Execute<TAToJellyfinProgressSyncTask>();
}
var isJFTATaskPresent = taskManager.ScheduledTasks.Any(t => t.Name.Equals(jfToTubearchivistProgressSyncTask.Name, StringComparison.Ordinal));
if (Instance!.Configuration.JFTASync && !isJFTATaskPresent)
{
logger.LogInformation("Queueing task {TaskName}.", jfToTubearchivistProgressSyncTask.Name);
taskManager.AddTasks([jfToTubearchivistProgressSyncTask]);
taskManager.Execute<JFToTubearchivistProgressSyncTask>();
}
logger.LogInformation("{Message}", "Collection display name: " + Instance?.Configuration.CollectionTitle);
logger.LogInformation("{Message}", "TubeArchivist API URL: " + Instance?.Configuration.TubeArchivistUrl);
logger.LogInformation("{Message}", "Pinging TubeArchivist API...");
@@ -128,6 +114,23 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
}
};
/// <summary>
/// Updates the HTTP client's Authorization header with the current API key.
/// </summary>
/// <param name="apiKey">TubeArchivist API key.</param>
public void UpdateAuthorizationHeader(string apiKey)
{
if (!string.IsNullOrEmpty(apiKey))
{
HttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Token", apiKey);
Logger.LogInformation("{Message}", "Updated Authorization header with API key");
}
else
{
Logger.LogWarning("{Message}", "No TubeArchivist API key configured");
}
}
/// <summary>
/// Logs the TubeArchivist API connection status.
/// </summary>
@@ -153,48 +156,114 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
private async void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs eventArgs)
{
if (Instance!.Configuration.JFTASync && eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)))
if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
{
BaseItem? channel = LibraryManager.GetItemById(eventArgs.Item.ParentId);
BaseItem? collection = LibraryManager.GetItemById(channel!.ParentId);
if (collection?.Name.ToLower(CultureInfo.CurrentCulture) == Instance?.Configuration.CollectionTitle.ToLower(CultureInfo.CurrentCulture) && eventArgs.PlaybackPositionTicks != null)
Logger.LogDebug("Skipping progress synchronization: PlaybackProgress event triggered with null or empty Guid.");
return;
}
var topParent = eventArgs.Item.GetTopParent();
if (
Instance!.Configuration.JFTAProgressSync &&
eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)) &&
eventArgs.PlaybackPositionTicks.HasValue &&
string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
)
{
long progress = (long)eventArgs.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
try
{
long progress = (long)eventArgs.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
var statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
}
}
catch (Exception ex)
{
Logger.LogCritical("An exception occurred while calling POST /video/{VideoId}/progress for for video {VideoName} with progress {Progress} seconds: {ExceptionMessage}", videoId, eventArgs.Item.Name, progress, ex.Message);
}
}
}
private async void OnWatchedStatusChange(object? sender, UserDataSaveEventArgs eventArgs)
{
var user = _userManager.GetUserById(eventArgs.UserId);
if (user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
{
var isPlayed = eventArgs.Item.IsPlayed(user);
Logger.LogDebug("Skipping watched status synchronization: WatchedStatusChange event triggered with null or empty Guid.");
return;
}
var topParent = eventArgs.Item.GetTopParent();
var user = _userManager.GetUserById(eventArgs.UserId);
if (
Configuration.JFTAProgressSync
&& user == null
&& string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
)
{
Logger.LogError("OnWatchedStatusChange callback called without user id for item {ItemName}", eventArgs.Item.Name);
return;
}
if (
Configuration.JFTAProgressSync
&& user != null
&& string.Equals(Configuration.JFUsernameFrom, user!.Username, StringComparison.Ordinal)
&& string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
)
{
var userItemData = _userDataManager.GetUserData(user, eventArgs.Item);
var isPlayed = eventArgs.Item.IsPlayed(user, userItemData);
Logger.LogDebug("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
string itemYTId;
if (eventArgs.Item is Series)
try
{
itemYTId = Utils.GetChannelNameFromPath(eventArgs.Item.Path);
if (eventArgs.Item is Series)
{
itemYTId = Utils.GetChannelNameFromPath(eventArgs.Item.Path);
}
else if (eventArgs.Item is Episode)
{
itemYTId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
}
else
{
return;
}
}
else if (eventArgs.Item is Episode)
{
itemYTId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
}
else
catch (Exception ex)
{
Logger.LogError(ex, "Error while processing item path: {ItemPath}", eventArgs.Item.Path ?? "null");
return;
}
var statusCode = await TubeArchivistApi.GetInstance().SetWatchedStatus(itemYTId, isPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
try
{
Logger.LogCritical("POST /watched returned {StatusCode} for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}", statusCode, eventArgs.Item.Name, itemYTId, isPlayed);
var statusCode = await TubeArchivistApi.GetInstance().SetWatchedStatus(itemYTId, isPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
Logger.LogCritical("POST /watched returned {StatusCode} for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}", statusCode, eventArgs.Item.Name, itemYTId, isPlayed);
}
if (eventArgs.Item is Episode)
{
var progress = _userDataManager.GetUserData(user, eventArgs.Item)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
if (progress != null)
{
var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress.Value).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
}
}
}
}
catch (Exception ex)
{
Logger.LogCritical("An exception occurred while calling POST /watched for item {ItemName} ({VideoYTId}) with watched status {IsPlayed}: {ExceptionMessage}", eventArgs.Item.Name, itemYTId, isPlayed, ex.Message);
}
}
}
@@ -47,7 +47,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public string Name => "TubeArchivist";
/// <inheritdoc />
public bool Supports(BaseItem item) => item is Episode;
public bool Supports(BaseItem item) => item is Episode && Utils.IsTubeArchivistItem(item);
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
@@ -62,10 +62,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
var taApi = TubeArchivistApi.GetInstance();
var videoTAId = Utils.GetVideoNameFromPath(item.Path);
var video = await taApi.GetVideo(videoTAId).ConfigureAwait(true);
_logger.LogInformation("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting images for video: {0} ({1})", video?.Title, videoTAId));
_logger.LogInformation("{Message}", "Thumb URI: " + video?.VidThumbUrl);
_logger.LogDebug("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting images for video: {0} ({1})", video?.Title, videoTAId));
_logger.LogDebug("{Message}", "Thumb URI: " + video?.VidThumbUrl);
if (video != null)
if (video != null && Utils.HasImageUrl(video.VidThumbUrl))
{
list.Add(new RemoteImageInfo
{
@@ -87,6 +87,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
}
else
{
if (!Utils.HasImageUrl(url))
{
throw new HttpRequestException("TubeArchivist returned an empty image URL.");
}
return await Plugin.Instance.HttpClient.GetAsync(new Uri(Utils.SanitizeUrl(Plugin.Instance.Configuration.TubeArchivistUrl + url).TrimEnd('/')), cancellationToken).ConfigureAwait(false);
}
}
@@ -50,11 +50,16 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Episode>();
if (string.IsNullOrWhiteSpace(info.Path))
{
return result;
}
var taApi = TubeArchivistApi.GetInstance();
var videoTAId = Utils.GetVideoNameFromPath(info.Path);
var video = await taApi.GetVideo(videoTAId).ConfigureAwait(true);
_logger.LogInformation("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting metadata for video: {0} ({1})", video?.Title, videoTAId));
_logger.LogInformation("{Message}", "Received metadata: \n" + JsonConvert.SerializeObject(video));
_logger.LogDebug("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting metadata for video: {0} ({1})", video?.Title, videoTAId));
_logger.LogDebug("{Message}", "Received metadata: \n" + JsonConvert.SerializeObject(video));
if (video != null)
{
@@ -67,6 +72,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
});
result.HasMetadata = true;
result.Item = video.ToEpisode();
result.Item.Path = info.Path;
result.Provider = Name;
result.People = peopleInfo;
}
@@ -78,6 +84,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
if (string.IsNullOrWhiteSpace(searchInfo.Path))
{
return results;
}
var taApi = TubeArchivistApi.GetInstance();
var videoTAId = Utils.GetVideoNameFromPath(searchInfo.Path);
@@ -45,12 +45,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public string Name => "TubeArchivist";
/// <inheritdoc />
public bool Supports(BaseItem item) => item is Series;
public bool Supports(BaseItem item) => item is Series && Utils.IsTubeArchivistItem(item);
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new[] { ImageType.Primary };
return new[] { ImageType.Primary, ImageType.Art, ImageType.Banner, ImageType.Backdrop };
}
/// <inheritdoc />
@@ -60,12 +60,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
var taApi = TubeArchivistApi.GetInstance();
var channelTAId = Utils.GetChannelNameFromPath(item.Path);
var channel = await taApi.GetChannel(channelTAId).ConfigureAwait(true);
_logger.LogInformation("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting images for channel: {0} ({1})", channel?.Name, channelTAId));
_logger.LogInformation("{Message}", "Thumb URI: " + channel?.ThumbUrl);
_logger.LogInformation("{Message}", "TVArt URI: " + channel?.TvartUrl);
_logger.LogInformation("{Message}", "Banner URI: " + channel?.BannerUrl);
_logger.LogDebug("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting images for channel: {0} ({1})", channel?.Name, channelTAId));
_logger.LogDebug("{Message}", "Thumb URI: " + channel?.ThumbUrl);
if (channel != null)
if (channel != null && Utils.HasImageUrl(channel.ThumbUrl))
{
list.Add(new RemoteImageInfo
{
@@ -73,18 +71,17 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
Type = ImageType.Primary,
Url = channel.ThumbUrl
});
}
if (channel != null && Utils.HasImageUrl(channel.TvartUrl))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Type = ImageType.Art,
Url = channel.TvartUrl
});
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Type = ImageType.Banner,
Url = channel.BannerUrl
});
list.Add(new RemoteImageInfo
{
ProviderName = Name,
@@ -93,6 +90,16 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
});
}
if (channel != null && Utils.HasImageUrl(channel.BannerUrl))
{
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Type = ImageType.Banner,
Url = channel.BannerUrl
});
}
return list;
}
@@ -105,6 +112,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
}
else
{
if (!Utils.HasImageUrl(url))
{
throw new HttpRequestException("TubeArchivist returned an empty image URL.");
}
return await Plugin.Instance.HttpClient.GetAsync(new Uri(Utils.SanitizeUrl(Plugin.Instance.Configuration.TubeArchivistUrl + url).TrimEnd('/')), cancellationToken).ConfigureAwait(false);
}
}
@@ -49,11 +49,16 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Series>();
if (string.IsNullOrWhiteSpace(info.Path))
{
return result;
}
var taApi = TubeArchivistApi.GetInstance();
var channelTAId = Utils.GetChannelNameFromPath(info.Path);
var channel = await taApi.GetChannel(channelTAId).ConfigureAwait(true);
_logger.LogInformation("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting metadata for channel: {0} ({1})", channel?.Name, channelTAId));
_logger.LogInformation("{Message}", "Received metadata: \n" + JsonConvert.SerializeObject(channel));
_logger.LogDebug("{Message}", string.Format(CultureInfo.CurrentCulture, "Getting metadata for channel: {0} ({1})", channel?.Name, channelTAId));
_logger.LogDebug("{Message}", "Received metadata: \n" + JsonConvert.SerializeObject(channel));
if (channel != null)
{
@@ -77,6 +82,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
if (string.IsNullOrWhiteSpace(searchInfo.Path))
{
return results;
}
var taApi = TubeArchivistApi.GetInstance();
var channelTAId = Utils.GetChannelNameFromPath(searchInfo.Path);
@@ -0,0 +1,437 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using J2N.Collections.Generic.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist;
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
/// <summary>
/// Task to sync TubeArchivist playlists to Jellyfin.
/// </summary>
public class JFToTubeArchivistPlaylistsSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IPlaylistManager _playlistManager;
/// <summary>
/// Initializes a new instance of the <see cref="JFToTubeArchivistPlaylistsSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="playlistManager">Playlists manager.</param>
public JFToTubeArchivistPlaylistsSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager)
{
_logger = logger;
_libraryManager = libraryManager;
_userManager = userManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public string Name => "JFToTubeArchivistPlaylistsSyncTask";
/// <inheritdoc/>
public string Description => "This tasks syncs Jellyfin playlists to TubeArchivist";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
/// <inheritdoc/>
public string Key => "JFToTubeArchivistPlaylistsSyncTask";
private Dictionary<Guid, List<BaseItem>> GetAllVideos(IEnumerable<MediaBrowser.Controller.Playlists.Playlist> playlists, User user)
{
var items = new Dictionary<Guid, List<BaseItem>>();
foreach (var playlist in playlists)
{
items[playlist.Id] = new List<BaseItem>(playlist.GetChildren(user, true, new InternalItemsQuery()));
}
return items;
}
private int CountTotalVideos(Dictionary<Guid, List<BaseItem>> playlistsItems)
{
var totalVideosCount = 0;
foreach (var playlistId in playlistsItems.Keys)
{
totalVideosCount += playlistsItems[playlistId].Count;
}
return totalVideosCount;
}
private string GetPlaylistUpdatedName(string playlistName, string newId)
{
var index = playlistName.LastIndexOf(" (", StringComparison.CurrentCulture);
if (index < 0)
{
return $"{playlistName} ({newId})";
}
else
{
var authorAndName = playlistName.Substring(0, index);
return $"{authorAndName} ({newId})";
}
}
private void MoveElementToPosition(Collection<PlaylistEntry> playlistEntries, int oldPosition, int newPosition)
{
var oldItem = playlistEntries[oldPosition];
playlistEntries.RemoveAt(oldPosition);
playlistEntries.Insert(newPosition, oldItem);
}
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(0);
if (Plugin.Instance!.Configuration.JFTAPlaylistsSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting Jellyfin->TubeArchivist playlists synchronization.");
var taApi = TubeArchivistApi.GetInstance();
var taPlaylists = await taApi.GetPlaylists().ConfigureAwait(true);
var jfUsername = Plugin.Instance!.Configuration.JFUsernameFrom;
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
return;
}
var userPlaylists = _playlistManager.GetPlaylists(user.Id).ToList();
var jfItemsToAnalyze = GetAllVideos(userPlaylists, user);
var totalVideosCount = CountTotalVideos(jfItemsToAnalyze);
var processedVideosCount = 0;
_logger.LogInformation("Found a total of {PlaylistsCount} playlists to analyze with a total of {VideosCount} videos", userPlaylists.Count, totalVideosCount);
if (taPlaylists != null && Plugin.Instance!.Configuration.JFTAPlaylistsDelete)
{
var taPlaylistsToDelete = taPlaylists.Where(tp => !userPlaylists.Select(up => Utils.GetTAPlaylistIdFromName(up.Name)).Contains(tp.Id));
foreach (var taPlaylistToDelete in taPlaylistsToDelete)
{
_logger.LogInformation("Deleting TubeArchivist playlist {PlaylistName} ({PlaylistId})", taPlaylistToDelete.Name, taPlaylistToDelete.Id);
await taApi.DeletePlaylist(taPlaylistToDelete.Id).ConfigureAwait(true);
}
}
foreach (var jfPlaylist in userPlaylists)
{
_logger.LogInformation("Analyzing playlist {PlaylistName}...", jfPlaylist.Name);
var taPlaylistId = Utils.GetTAPlaylistIdFromName(jfPlaylist.Name);
if (taPlaylistId == null)
{
_logger.LogDebug("The playlist {PlaylistName} was not a TA playlist: could not find TA playlist id at the end", jfPlaylist.Name);
continue;
}
// Try to find a TA playlist with matching id
// N.B.: only videos with TubeArchivist provider can be synced to TA, if there is a non matching video log a warn
var taPlaylist = taPlaylists?.Where(tp => tp.Id == taPlaylistId).FirstOrDefault();
if (taPlaylist != null)
{
// If it exists and is custom add new videos and update videos order
if (taPlaylist.Type == PlaylistType.Custom)
{
var jfItems = jfItemsToAnalyze[jfPlaylist.Id];
_logger.LogInformation("Found {PlaylistVideosCount} videos in playlist {PlaylistName}", jfItems.Count, jfPlaylist.Name);
var itemsToProcess = new List<PlaylistItemAction>();
// Add videos to move/add to the TA playlist
for (var i = 0; i < jfItems.Count; i++)
{
if (!jfItems[i].ProviderIds.ContainsKey(Constants.ProviderName))
{
_logger.LogError("Could not sync {JFItem} video from playlist {JFPlaylist} because it doesn't belong to TubeArchivist", jfItems[i].Name, jfPlaylist.Name);
continue;
}
var position = taPlaylist.Entries.FindIndex(e => e.YoutubeId == jfItems[i].ProviderIds[Constants.ProviderName]);
PlaylistItemAction action;
if (position < 0)
{
_logger.LogDebug("Video {VideoName} from playlist {PlaylistName} not found in TA playlist", jfItems[i].Name, jfPlaylist.Name);
var positionsToMove = i - position;
action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Create, positionsToMove, jfItems[i].Name);
taPlaylist.Entries.Insert(i, new PlaylistEntry(jfItems[i].ProviderIds[Constants.ProviderName], jfItems[i].Name, jfItems[i].Studios[0], i, true));
}
else
{
_logger.LogDebug("Found video {VideoName} at position {JFPosition} in playlist {PlaylistName} at position {TAPosition} in TA playlist", jfItems[i].Name, i, jfPlaylist.Name, position);
if (i == 0)
{
action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Top, jfItems[i].Name);
}
else if (i == jfItems.Count)
{
action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], CustomPlaylistAction.Bottom, jfItems[i].Name);
}
else
{
var positionsToMove = i - position;
action = new PlaylistItemAction(i, jfItems[i].ProviderIds[Constants.ProviderName], positionsToMove > 0 ? CustomPlaylistAction.Up : CustomPlaylistAction.Down, Math.Abs(positionsToMove), jfItems[i].Name);
}
var temp = taPlaylist.Entries[position];
taPlaylist.Entries.RemoveAt(position);
taPlaylist.Entries.Insert(i, temp);
}
itemsToProcess.Add(action);
}
// Add videos to delete from the TA playlist
var itemsToDelete = taPlaylist.Entries
.Where(e => !itemsToProcess
.Select(i => i.YoutubeId)
.Contains(e.YoutubeId))
.ToList();
foreach (var item in itemsToDelete)
{
_logger.LogDebug("Video {VideoName} not found in JF playlist, marking for deletion", item.Title);
var ret = taPlaylist.Entries.Remove(item);
}
itemsToProcess.AddRange(itemsToDelete.Select(e => new PlaylistItemAction(e.YoutubeId, e.Title)));
foreach (var item in itemsToProcess)
{
_logger.LogDebug("Analyzing video {VideoName} from playlist {PlaylistName} at position {Position}", item.Name, jfPlaylist.Name, item.Index);
switch (item.Action)
{
case CustomPlaylistAction.Create:
var success = await AddVideoToTAPlaylist(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true);
if (!success)
{
continue;
}
break;
case CustomPlaylistAction.Top:
case CustomPlaylistAction.Bottom:
_logger.LogDebug("Moving the video {VideoName} to the {Side} of the playlist {PlaylistName}", item.Name, item.Action, jfPlaylist.Name);
await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true);
break;
case CustomPlaylistAction.Remove:
_logger.LogDebug("Removing the video {VideoName} in playlist {PlaylistName}", item.Name, jfPlaylist.Name);
await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true);
break;
default:
_logger.LogDebug("Moving the video {VideoName} {Direction} in playlist {PlaylistName}", item.Name, item.Action, jfPlaylist.Name);
await MoveOrDeleteVideo(taApi, jfPlaylist, taPlaylistId, item).ConfigureAwait(true);
break;
}
processedVideosCount++;
progress.Report(processedVideosCount * 100 / totalVideosCount);
}
}
else
{
_logger.LogWarning("Could not sync modifications on a YouTube downloaded TA playlist: {TAPlaylist}", $"{taPlaylist.Name} - {taPlaylist.Channel} ({taPlaylist.Id})");
continue;
}
}
else
{
// If it doesn't, create a new custom playlist and add videos
_logger.LogInformation("Playlist {PlaylistName} was not found on TubeArchivist. Creating a new playlist...", jfPlaylist.Name);
var playlistName = Utils.GetTAPlaylistNameFromName(jfPlaylist.Name);
if (string.IsNullOrEmpty(playlistName))
{
playlistName = jfPlaylist.Name;
}
_logger.LogDebug("Creating a new TubeArchivist playlist with name: {PlaylistName}", playlistName);
var creationRequest = new CustomPlaylistCreation(playlistName);
var createdTAPlaylist = await taApi.CreateCustomPlaylist(creationRequest).ConfigureAwait(true);
if (createdTAPlaylist == null)
{
_logger.LogError("Failed to create the playlist {JFPlaylist}", playlistName);
continue;
}
// Update the JF playlist name with the new TA playlist id
var newPlaylistName = GetPlaylistUpdatedName(jfPlaylist.Name, createdTAPlaylist.Id);
var updateRequest = new PlaylistUpdateRequest
{
Id = jfPlaylist.Id,
Name = newPlaylistName,
UserId = user.Id
};
await _playlistManager.UpdatePlaylist(updateRequest).ConfigureAwait(true);
_logger.LogDebug("Updated the playlist name from {NewPlaylistName} to {NewPlaylistName}", playlistName, newPlaylistName);
foreach (var jfItem in jfPlaylist.GetItemList(new InternalItemsQuery()))
{
_logger.LogDebug("Adding the video {VideoName} to the playlist {PlaylistName}", jfItem.Name, jfPlaylist.Name);
var action = new CustomPlaylistEntryAction(CustomPlaylistAction.Create, jfItem.ProviderIds[Constants.ProviderName]);
var response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true);
if (response != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Failed to add the video {JFItem} to playlist {JFPlaylist}", $"{jfItem.Name} ({jfItem.ProviderIds[Constants.ProviderName]})", jfPlaylist.Name);
continue;
}
processedVideosCount++;
progress.Report(processedVideosCount * 100 / totalVideosCount);
}
}
}
_logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start);
}
else
{
_logger.LogInformation("Jellyfin->TubeArchivist playlists synchronization is currently disabled.");
}
progress.Report(100);
}
private async Task MoveOrDeleteVideo(TubeArchivistApi taApi, MediaBrowser.Controller.Playlists.Playlist jfPlaylist, string taPlaylistId, PlaylistItemAction item)
{
var response = HttpStatusCode.OK;
for (var i = 0; i < item.PositionsToMove; i++)
{
var action = new CustomPlaylistEntryAction(item.Action, item.YoutubeId);
response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true);
if (response != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Failed to execute the operation {Operation} for the video {JFItem} in playlist {PlaylistName}", item.Action, $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name);
}
}
}
private async Task<bool> AddVideoToTAPlaylist(TubeArchivistApi taApi, MediaBrowser.Controller.Playlists.Playlist jfPlaylist, string taPlaylistId, PlaylistItemAction item)
{
// TA playlist doesn't contain the JF video
_logger.LogDebug("Adding the video {VideoName} to the playlist {PlaylistName}", item.Name, jfPlaylist.Name);
var action = new CustomPlaylistEntryAction(CustomPlaylistAction.Create, item.YoutubeId);
var response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true);
if (response != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Failed to add the video {JFItem} to playlist {JFPlaylist}", $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name);
return false;
}
// Since now we have the video added at the bottom of the TA playlist (previouse entries count + 1),
// we need to move the video to the correct position
var positionsToMove = item.PositionsToMove;
action = new CustomPlaylistEntryAction(CustomPlaylistAction.Up, item.YoutubeId);
if (item.PositionsToMove < 0)
{
positionsToMove = (int)Math.Abs((decimal)positionsToMove!);
action = new CustomPlaylistEntryAction(CustomPlaylistAction.Up, item.YoutubeId);
}
_logger.LogDebug("Moving the video {VideoName} of the playlist {PlaylistName} {Positions} up", item.Name, jfPlaylist.Name, positionsToMove);
for (var i = 0; i < positionsToMove; i++)
{
response = await taApi.CustomPlaylistEntryAction(taPlaylistId, action).ConfigureAwait(true);
if (response != System.Net.HttpStatusCode.OK)
{
_logger.LogError("Failed to move {Direction} the video {JFItem} in playlist {JFPlaylist}", item.Action, $"{item.Name} ({item.YoutubeId})", jfPlaylist.Name);
continue;
}
}
return true;
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return
[
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.JFTAPlaylistsSyncTaskInterval).Ticks
},
];
}
private sealed class PlaylistItemAction
{
public PlaylistItemAction(
int index,
string youtubeId,
CustomPlaylistAction action,
int positionsToMove,
string name)
{
Index = index;
YoutubeId = youtubeId;
Action = action;
PositionsToMove = positionsToMove;
Name = name;
}
public PlaylistItemAction(
int index,
string youtubeId,
CustomPlaylistAction action,
string name)
{
Index = index;
YoutubeId = youtubeId;
Action = action;
Name = name;
PositionsToMove = 1;
}
public PlaylistItemAction(
string youtubeId,
string name)
{
YoutubeId = youtubeId;
Action = CustomPlaylistAction.Remove;
Name = name;
PositionsToMove = 1;
}
public int? Index { get; set; }
public string YoutubeId { get; set; }
public string Name { get; set; }
public CustomPlaylistAction Action { get; set; }
public int? PositionsToMove { get; set; }
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
@@ -19,7 +20,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
/// <summary>
/// Task to sync Jellyfin playback progresses to TubeArchivist.
/// </summary>
public class JFToTubearchivistProgressSyncTask : IScheduledTask
public class JFToTubeArchivistProgressSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
@@ -27,13 +28,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
private readonly IUserDataManager _userDataManager;
/// <summary>
/// Initializes a new instance of the <see cref="JFToTubearchivistProgressSyncTask"/> class.
/// Initializes a new instance of the <see cref="JFToTubeArchivistProgressSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="userDataManager">User data manager.</param>
public JFToTubearchivistProgressSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager)
public JFToTubeArchivistProgressSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager)
{
_logger = logger;
_libraryManager = libraryManager;
@@ -45,7 +46,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public string Name => "JFToTubeArchivistProgressSyncTask";
/// <inheritdoc/>
public string Description => "This tasks syncs TubeArchivist playback progresses to Jellyfin";
public string Description => "This tasks syncs Jellyfin playback progresses to TubeArchivist";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
@@ -57,152 +58,167 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(0);
if (Plugin.Instance!.Configuration.JFTASync)
if (Plugin.Instance!.Configuration.JFTAProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting Jellyfin->TubeArchivist playback progresses synchronization.");
var taApi = TubeArchivistApi.GetInstance();
var videosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
var jfUsername = Plugin.Instance!.Configuration.JFUsernameFrom;
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
continue;
}
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
return;
}
var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
}).FirstOrDefault();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
});
if (collectionItem == null)
var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
var message = $"Collection '{Plugin.Instance?.Configuration.CollectionTitle}' not found.";
_logger.LogCritical("{Message}", message);
}
else
{
var collection = (CollectionFolder)collectionItem;
var channels = collection.GetChildren(user, false, new InternalItemsQuery
{
var message = $"Collection '{Plugin.Instance?.Configuration.CollectionTitle}' not found.";
_logger.LogCritical("{Message}", message);
}
else
IncludeItemTypes = new[] { BaseItemKind.Series }
});
_logger.LogDebug("Analyzing collection {Id} with name {Name}", collectionItem.Id, collectionItem.Name);
_logger.LogDebug("Found {Message} channels", channels.Count);
foreach (Series channel in channels)
{
var collection = (CollectionFolder)collectionItem;
var channels = collection.GetChildren(user, false, new InternalItemsQuery
var channelYTId = Utils.GetChannelNameFromPath(channel.Path);
var years = channel.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series }
IncludeItemTypes = new[] { BaseItemKind.Season }
});
_logger.LogInformation("Analyzing collection {Id} with name {Name}", collectionItem.Id, collectionItem.Name);
_logger.LogDebug("Found {Message} channels", channels.Count);
_logger.LogDebug("Found {Years} years in channel {ChannelName}", years.Count, channel.Name);
foreach (Series channel in channels)
foreach (Season year in years)
{
var channelYTId = Utils.GetChannelNameFromPath(channel.Path);
var years = channel.GetChildren(user, false, new InternalItemsQuery
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Season }
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
_logger.LogDebug("Found {Years} years in channel {ChannelName}", years.Count, channel.Name);
foreach (Season year in years)
{
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
_logger.LogDebug("Found {Videos} videos in year {YearName} of the channel {ChannelName}", videos.Count, year.Name, channel.Name);
videosCount += videos.Count;
}
_logger.LogDebug("Found {Videos} videos in year {YearName} of the channel {ChannelName}", videos.Count, year.Name, channel.Name);
videosCount += videos.Count;
}
}
}
_logger.LogInformation("Found a total of {VideosCount} videos", videosCount);
_logger.LogDebug("Found a total of {VideosCount} videos", videosCount);
var processedVideosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
if (collectionItem == null)
{
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
var message = $"Collection '{Plugin.Instance?.Configuration.CollectionTitle}' not found.";
_logger.LogCritical("{Message}", message);
}
else
{
var collection = (CollectionFolder)collectionItem;
var channels = collection.GetChildren(user, false, new InternalItemsQuery
{
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
continue;
}
IncludeItemTypes = new[] { BaseItemKind.Series }
});
var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
foreach (Series channel in channels)
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
}).FirstOrDefault();
if (collectionItem == null)
{
var message = $"Collection '{Plugin.Instance?.Configuration.CollectionTitle}' not found.";
_logger.LogCritical("{Message}", message);
}
else
{
var collection = (CollectionFolder)collectionItem;
var channels = collection.GetChildren(user, false, new InternalItemsQuery
var channelYTId = Utils.GetChannelNameFromPath(channel.Path);
var isChannelWatched = false;
var isChannelCheckedForWatched = false;
var years = channel.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series }
IncludeItemTypes = new[] { BaseItemKind.Season }
});
foreach (Series channel in channels)
foreach (Season year in years)
{
var channelYTId = Utils.GetChannelNameFromPath(channel.Path);
var isChannelWatched = false;
var isChannelCheckedForWatched = false;
var years = channel.GetChildren(user, false, new InternalItemsQuery
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Season }
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
videosCount += videos.Count;
foreach (Season year in years)
foreach (Episode video in videos)
{
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
videosCount += videos.Count;
var videoYTId = Utils.GetVideoNameFromPath(video.Path);
_logger.LogDebug("Current video extracted YouTube id: {VideoYtId}", videoYTId);
HttpStatusCode statusCode;
var channelItemData = _userDataManager.GetUserData(user, channel);
foreach (Episode video in videos)
if (!isChannelCheckedForWatched && channel.IsPlayed(user, channelItemData))
{
var videoYTId = Utils.GetVideoNameFromPath(video.Path);
var playbackProgress = _userDataManager.GetUserData(user.Id, video).PlaybackPositionTicks / TimeSpan.TicksPerSecond;
var statusCode = await taApi.SetProgress(videoYTId, playbackProgress).ConfigureAwait(true);
var isChannelPlayed = channel.IsPlayed(user, channelItemData);
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogCritical("{Message}", $"POST /video/{videoYTId}/progress returned {statusCode} for video {video.Name} with progress {progress} seconds");
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for channel {channel.Name} ({channelYTId}) with wacthed status {isChannelPlayed}");
}
if (!isChannelCheckedForWatched && channel.IsPlayed(user))
else
{
var isChannelPlayed = channel.IsPlayed(user);
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for channel {channel.Name} ({channelYTId}) with wacthed status {isChannelPlayed}");
}
else
{
isChannelWatched = true;
}
isChannelCheckedForWatched = true;
isChannelWatched = true;
}
if (!isChannelWatched)
{
var isVideoPlayed = video.IsPlayed(user);
statusCode = await taApi.SetWatchedStatus(videoYTId, isVideoPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for video {video.Name} ({videoYTId}) with wacthed status {isVideoPlayed}");
}
}
processedVideosCount++;
progress.Report(processedVideosCount * 100 / videosCount);
isChannelCheckedForWatched = true;
}
var videoItemData = _userDataManager.GetUserData(user, video);
if (!isChannelWatched)
{
var isVideoPlayed = video.IsPlayed(user, videoItemData);
var taVideo = await taApi.GetVideo(videoYTId).ConfigureAwait(true);
if (taVideo != null)
{
var isTAVideoPlayed = taVideo?.Player.IsWatched ?? false;
if (isTAVideoPlayed != isVideoPlayed)
{
statusCode = await taApi.SetWatchedStatus(videoYTId, isVideoPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for video {video.Name} ({videoYTId}) with wacthed status {isVideoPlayed}");
}
else
{
_logger.LogInformation("Video {VideoId} watch status marked as {Status} in TubeArchivist", videoYTId, isVideoPlayed);
}
}
}
_logger.LogDebug("{Message}", isVideoPlayed);
if (!isVideoPlayed)
{
var playbackProgress = _userDataManager.GetUserData(user, video)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
if (playbackProgress != null)
{
try
{
statusCode = await taApi.SetProgress(videoYTId, playbackProgress.Value).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogCritical("{Message}", $"POST /video/{videoYTId}/progress returned {statusCode} for video {video.Name} with progress {progress} seconds");
}
}
catch (Exception ex)
{
_logger.LogCritical("An exception occurred while calling POST /video/{VideoId}/progress for for video {VideoName} with progress {Progress} seconds: {ExceptionMessage}", videoYTId, videoYTId, playbackProgress.Value, ex.Message);
}
}
}
}
processedVideosCount++;
progress.Report(processedVideosCount * 100 / videosCount);
}
}
}
@@ -225,7 +241,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
[
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerStartup,
Type = TaskTriggerInfoType.StartupTrigger,
},
];
}
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist;
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
/// <summary>
/// Task to sync TubeArchivist playlists to Jellyfin.
/// </summary>
public class TAToJellyfinPlaylistsSyncTask : IScheduledTask
{
private readonly ILogger<Plugin> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IPlaylistManager _playlistManager;
/// <summary>
/// Initializes a new instance of the <see cref="TAToJellyfinPlaylistsSyncTask"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="libraryManager">Library manager.</param>
/// <param name="userManager">User manager.</param>
/// <param name="playlistManager">Playlists manager.</param>
public TAToJellyfinPlaylistsSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager)
{
_logger = logger;
_libraryManager = libraryManager;
_userManager = userManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public string Name => "TAToJellyfinPlaylistsSyncTask";
/// <inheritdoc/>
public string Description => "This tasks syncs TubeArchivist playlists to Jellyfin";
/// <inheritdoc/>
public string Category => "TubeArchivistMetadata";
/// <inheritdoc/>
public string Key => "TAToJellyfinPlaylistsSyncTask";
private int CountTotalVideos(ISet<TubeArchivist.Playlist> taPlaylists)
{
var totalEntries = 0;
foreach (var taPlaylist in taPlaylists)
{
totalEntries += taPlaylist.Entries.Count;
}
return totalEntries;
}
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(0);
if (Plugin.Instance!.Configuration.TAJFPlaylistsSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting TubeArchivist->Jellyfin playlists synchronization.");
var taApi = TubeArchivistApi.GetInstance();
_logger.LogInformation("Getting TubeArchivist playlists");
var taPlaylists = await taApi.GetPlaylists().ConfigureAwait(true);
_logger.LogInformation("Received playlists:\n{Playlists}", taPlaylists);
if (taPlaylists != null)
{
var totalVideosCount = CountTotalVideos(taPlaylists);
var processedVideosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
{
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
continue;
}
var userPlaylists = _playlistManager.GetPlaylists(user.Id).ToList();
if (Plugin.Instance!.Configuration.TAJFPlaylistsDelete)
{
var jfPlaylistsToDelete = userPlaylists.Where(up => !taPlaylists.Select(tp => tp.Id).Contains(Utils.GetTAPlaylistIdFromName(up.Name)));
foreach (var jfPlaylistToDelete in jfPlaylistsToDelete)
{
_logger.LogInformation("Deleting Jellyfin playlist {PlaylistName}", jfPlaylistToDelete.Name);
_libraryManager.DeleteItem(jfPlaylistToDelete, new DeleteOptions());
}
}
foreach (var taPlaylist in taPlaylists)
{
var playlistName = taPlaylist.Type == PlaylistType.Regular ? $"{taPlaylist.Name} - {taPlaylist.Channel} ({taPlaylist.Id})" : $"{taPlaylist.Name} ({taPlaylist.Id})";
var userPlaylist = userPlaylists.Where(up => up.Name == playlistName).FirstOrDefault();
var jfEntryIds = new List<Guid>();
var currentPlaylistVideos = 0;
foreach (var taEntry in taPlaylist.Entries)
{
currentPlaylistVideos++;
var taEntryStr = $"{taEntry.Uploader} - {taEntry.Title} ({taEntry.YoutubeId}) in playlist {playlistName}";
if (!taEntry.IsDownloaded)
{
_logger.LogInformation("The entry {TAEntry} was skipped because has not been downloaded by TubeArchivist", taEntryStr);
continue;
}
var entries = _libraryManager.GetItemList(new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string>()
{
{
Constants.ProviderName, taEntry.YoutubeId
}
}
});
var jfEntry = entries.Count > 0 ? entries[0] : null;
if (jfEntry != null)
{
jfEntryIds.Add(jfEntry.Id);
}
else
{
_logger.LogWarning("The relative video for {TAEntry} was not found on Jellyfin", taEntryStr);
}
}
if (userPlaylist != null)
{
var updateRequest = new PlaylistUpdateRequest
{
Id = userPlaylist.Id,
UserId = user.Id,
Name = playlistName,
Ids = jfEntryIds
};
var result = _playlistManager.UpdatePlaylist(updateRequest);
_logger.LogInformation("Updated playlist {PlaylistName} with id {Id}", playlistName, result.Id);
}
else
{
var creationRequest = new PlaylistCreationRequest
{
Name = playlistName,
ItemIdList = jfEntryIds,
MediaType = MediaType.Video,
UserId = user.Id
};
var result = await _playlistManager.CreatePlaylist(creationRequest).ConfigureAwait(true);
_logger.LogInformation("Created playlist {PlaylistName} with id {Id}", playlistName, result.Id);
}
processedVideosCount += currentPlaylistVideos;
if (totalVideosCount == 0)
{
progress.Report(100);
}
else
{
progress.Report(processedVideosCount * 100 / totalVideosCount);
}
}
}
}
_logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start);
}
else
{
_logger.LogInformation("TubeArchivist->Jellyfin playlists synchronization is currently disabled.");
}
progress.Report(100);
}
/// <inheritdoc/>
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return
[
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFPlaylistsSyncTaskInterval).Ticks
},
];
}
}
}
@@ -57,7 +57,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(0);
if (Plugin.Instance!.Configuration.TAJFSync)
if (Plugin.Instance!.Configuration.TAJFProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting TubeArchivist->Jellyfin playback progresses synchronization.");
@@ -72,11 +72,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
continue;
}
var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
}).FirstOrDefault();
});
var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -90,7 +92,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
IncludeItemTypes = new[] { BaseItemKind.Series }
});
_logger.LogInformation("Analyzing collection {Id} with name {Name}", collectionItem.Id, collectionItem.Name);
_logger.LogDebug("Analyzing collection {Id} with name {Name}", collectionItem.Id, collectionItem.Name);
_logger.LogDebug("Found {Message} channels", channels.Count);
foreach (Series channel in channels)
@@ -115,7 +117,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
}
}
_logger.LogInformation("Found a total of {VideosCount} videos", videosCount);
_logger.LogDebug("Found a total of {VideosCount} videos", videosCount);
var processedVideosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
@@ -123,15 +125,17 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
_logger.LogDebug("{Message}", $"Jellyfin user with username {jfUsername} not found");
continue;
}
var collectionItem = _libraryManager.GetItemList(new InternalItemsQuery
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
}).FirstOrDefault();
});
var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -185,8 +189,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
}
_userDataManager.SaveUserData(user, video, userUpdateData, UserDataSaveReason.UpdateUserData);
_logger.LogInformation("{Message}", $"Playback progress for video {video.Name} set to {userItemData.PlaybackPositionTicks / TimeSpan.TicksPerSecond} seconds for user {jfUsername}.");
_logger.LogInformation("{Message}", $"Watched status for video {video.Name} set to {userItemData.Played} seconds for user {jfUsername}.");
_logger.LogInformation("{Message}", $"Playback progress for video {video.Name} set to {userItemData?.PlaybackPositionTicks / TimeSpan.TicksPerSecond} seconds for user {jfUsername}.");
_logger.LogInformation("{Message}", $"Watched status for video {video.Name} set to {userItemData?.Played} seconds for user {jfUsername}.");
processedVideosCount++;
progress.Report(processedVideosCount * 100 / videosCount);
@@ -214,8 +218,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
[
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFTaskInterval).Ticks
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
},
];
}
@@ -96,7 +96,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
Name = Name,
SearchProviderName = Constants.ProviderName,
ImageUrl = ThumbUrl,
ImageUrl = Utils.HasImageUrl(ThumbUrl) ? ThumbUrl : null,
ProviderIds = new Dictionary<string, string>() { { Constants.ProviderName, Id } }
};
}
@@ -118,15 +118,19 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
Constants.ProviderName, Id
}
},
ImageInfos = new[]
{
ImageInfos = Utils.HasImageUrl(ThumbUrl) ?
[
new ItemImageInfo
{
Path = ThumbUrl,
Type = ImageType.Primary
}
},
Tags = this.Tags.ToArray<string>()
] :
[],
Tags = this.Tags != null ?
this.Tags.ToArray<string>() :
[]
};
}
}
@@ -0,0 +1,99 @@
using System.Collections.ObjectModel;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// A class representing pagination information from the TubeArchivist API.
/// </summary>
public class PaginationInfo
{
/// <summary>
/// Initializes a new instance of the <see cref="PaginationInfo"/> class.
/// </summary>
/// <param name="pageSize">The size of the page.</param>
/// <param name="pageFrom">The starting page number.</param>
/// <param name="prevPages">The collection of previous page numbers.</param>
/// <param name="currentPage">The current page number.</param>
/// <param name="maxHits">A value indicating whether the max hits have been reached.</param>
/// <param name="parameters">The parameters used for pagination.</param>
/// <param name="lastPage">The last page number.</param>
/// <param name="nextPages">The collection of next page numbers.</param>
/// <param name="totalHits">The total number of hits.</param>
public PaginationInfo(
int pageSize,
int pageFrom,
Collection<int> prevPages,
int currentPage,
bool maxHits,
string parameters,
int lastPage,
Collection<int> nextPages,
int totalHits)
{
this.PageSize = pageSize;
this.PageFrom = pageFrom;
this.PrevPages = prevPages;
this.CurrentPage = currentPage;
this.MaxHits = maxHits;
this.Parameters = parameters;
this.LastPage = lastPage;
this.NextPages = nextPages;
this.TotalHits = totalHits;
}
/// <summary>
/// Gets the page size.
/// </summary>
[JsonProperty(PropertyName = "page_size")]
public int PageSize { get; }
/// <summary>
/// Gets the page from.
/// </summary>
[JsonProperty(PropertyName = "page_from")]
public int PageFrom { get; }
/// <summary>
/// Gets the previous pages.
/// </summary>
[JsonProperty(PropertyName = "prev_pages")]
public Collection<int> PrevPages { get; }
/// <summary>
/// Gets the current page.
/// </summary>
[JsonProperty(PropertyName = "current_page")]
public int CurrentPage { get; }
/// <summary>
/// Gets a value indicating whether the max hits have been reached.
/// </summary>
[JsonProperty(PropertyName = "max_hits")]
public bool MaxHits { get; }
/// <summary>
/// Gets the parameters.
/// </summary>
[JsonProperty(PropertyName = "params")]
public string Parameters { get; }
/// <summary>
/// Gets the last page.
/// </summary>
[JsonProperty(PropertyName = "last_page")]
public int LastPage { get; }
/// <summary>
/// Gets the next pages.
/// </summary>
[JsonProperty(PropertyName = "next_pages")]
public Collection<int> NextPages { get; }
/// <summary>
/// Gets the total hits.
/// </summary>
[JsonProperty(PropertyName = "total_hits")]
public int TotalHits { get; }
}
}
@@ -0,0 +1,25 @@
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// A class representing TubeArchivist API custom playlist creation request.
/// </summary>
public class CustomPlaylistCreation
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomPlaylistCreation"/> class.
/// </summary>
/// <param name="playlistName">The name of the new custom playlist to create.</param>
public CustomPlaylistCreation(string playlistName)
{
this.PlaylistName = playlistName;
}
/// <summary>
/// Gets or sets the name of the new custom playlist to create.
/// </summary>
[JsonProperty(PropertyName = "playlist_name")]
public string PlaylistName { get; set; }
}
}
@@ -0,0 +1,72 @@
using System.Globalization;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// Enum representing the possible actions to execute on a TubeArchivist custom playlist video.
/// </summary>
public enum CustomPlaylistAction
{
/// <summary>
/// Create a new custom playlist video.
/// </summary>
Create,
/// <summary>
/// Removea custom playlist video.
/// </summary>
Remove,
/// <summary>
/// Move a custom playlist video to the top of the list.
/// </summary>
Top,
/// <summary>
/// Move a custom playlist video to the bottom of the list.
/// </summary>
Bottom,
/// <summary>
/// Move a custom playlist video up in the list.
/// </summary>
Up,
/// <summary>
/// Move a custom playlist video down in the list.
/// </summary>
Down
}
/// <summary>
/// A class representing TubeArchivist API custom playlist entry operation request.
/// </summary>
public class CustomPlaylistEntryAction
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomPlaylistEntryAction"/> class.
/// </summary>
/// <param name="action">The standard action to create a new entry.</param>
/// <param name="videoId">The entry YoutubeId.</param>
public CustomPlaylistEntryAction(
CustomPlaylistAction action,
string videoId)
{
this.Action = action.ToString().ToLower(CultureInfo.CurrentCulture);
this.VideoId = videoId;
}
/// <summary>
/// Gets or sets the standard action to create a new entry.
/// </summary>
[JsonProperty(PropertyName = "action")]
public string Action { get; set; }
/// <summary>
/// Gets or sets the entry YoutubeId.
/// </summary>
[JsonProperty(PropertyName = "video_id")]
public string VideoId { get; set; }
}
}
@@ -0,0 +1,127 @@
using System.Collections.ObjectModel;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// Enum representing whether the playlist on TubeArchivist belongs from YouTube or is created by the user.
/// </summary>
public enum PlaylistType
{
/// <summary>
/// Playlist belongs from YouTube.
/// </summary>
Regular,
/// <summary>
/// Playlist created by the user.
/// </summary>
Custom
}
/// <summary>
/// A class representing TubeArchivist API playlist data.
/// </summary>
public class Playlist
{
/// <summary>
/// Initializes a new instance of the <see cref="Playlist"/> class.
/// </summary>
/// <param name="isActive">URL of the channel banner image.</param>
/// <param name="channel">Playlist channel name.</param>
/// <param name="channelId">Playlist channel YouTube id.</param>
/// <param name="description">Playlist description.</param>
/// <param name="entries">Playlist videos.</param>
/// <param name="id">Playlist YouTube id.</param>
/// <param name="name">Playlist name.</param>
/// <param name="thumbnailUrl">URL of the playlist thumbnail.</param>
/// <param name="type">Type of the playlist. See <see cref="PlaylistType"/>.</param>
public Playlist(
bool isActive,
string channel,
string channelId,
string description,
Collection<PlaylistEntry> entries,
string id,
string name,
string thumbnailUrl, // TODO: Check if settable on Jellyfin
PlaylistType type)
{
this.IsActive = isActive;
this.Channel = channel;
this.ChannelId = channelId;
this.Description = description;
this.Entries = entries;
this.Id = id;
this.Name = name;
this.ThumbnailUrl = thumbnailUrl;
this.Type = type;
}
/// <summary>
/// Gets or sets a value indicating whether the playlist is active or not.
/// </summary>
[JsonProperty(PropertyName = "playlist_active")]
public bool IsActive { get; set; }
/// <summary>
/// Gets or sets the playlist channel name.
/// </summary>
[JsonProperty(PropertyName = "playlist_channel")]
public string Channel { get; set; }
/// <summary>
/// Gets or sets the playlist channel YouTube id.
/// </summary>
[JsonProperty(PropertyName = "playlist_channel_id")]
public string ChannelId { get; set; }
/// <summary>
/// Gets or sets the playlist description.
/// </summary>
[JsonProperty(PropertyName = "playlist_description")]
public string Description { get; set; }
/// <summary>
/// Gets the playlist videos.
/// </summary>
[JsonProperty(PropertyName = "playlist_entries")]
public Collection<PlaylistEntry> Entries { get; }
/// <summary>
/// Gets or sets the playlist YouTube id.
/// </summary>
[JsonProperty(PropertyName = "playlist_id")]
public string Id { get; set; }
/// <summary>
/// Gets or sets the playlist name.
/// </summary>
[JsonProperty(PropertyName = "playlist_name")]
public string Name { get; set; }
/// <summary>
/// Gets or sets the playlist thumbnail URL.
/// </summary>
[JsonProperty(PropertyName = "playlist_thumbnail")]
public string ThumbnailUrl { get; set; }
/// <summary>
/// Gets or sets the playlist type.
/// </summary>
/// <value>One of the <see cref="PlaylistType"/> values indicating the playlist's type.</value>
[JsonProperty(PropertyName = "playlist_type")]
public PlaylistType Type { get; set; }
/// <summary>
/// Returns a string of the playlist's key fields for logging.
/// </summary>
/// <returns>A string containing the playlist id, name, and entries count.</returns>
public override string ToString()
{
return "PlaylistId: " + this.Id +
", Name: " + this.Name +
", EntriesCount: " + this.Entries.Count;
}
}
}
@@ -0,0 +1,63 @@
using ICU4N.Text;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
/// <summary>
/// A class representing TubeArchivist API playlist entries data.
/// </summary>
public class PlaylistEntry
{
/// <summary>
/// Initializes a new instance of the <see cref="PlaylistEntry"/> class.
/// </summary>
/// <param name="youtubeId">Video YouTube id.</param>
/// <param name="title">Video title.</param>
/// <param name="uploader">Video uploader.</param>
/// <param name="index">Video index in the playlist.</param>
/// <param name="isDownloaded">A value indicating whether the video has been already downloaded or not.</param>
public PlaylistEntry(
string youtubeId,
string title,
string uploader,
int index,
bool isDownloaded)
{
this.YoutubeId = youtubeId;
this.Title = title;
this.Uploader = uploader;
this.Index = index;
this.IsDownloaded = isDownloaded;
}
/// <summary>
/// Gets or sets the video YouTube id.
/// </summary>
[JsonProperty(PropertyName = "youtube_id")]
public string YoutubeId { get; set; }
/// <summary>
/// Gets or sets the video title.
/// </summary>
[JsonProperty(PropertyName = "title")]
public string Title { get; set; }
/// <summary>
/// Gets or sets the video uploader.
/// </summary>
[JsonProperty(PropertyName = "uploader")]
public string Uploader { get; set; }
/// <summary>
/// Gets or sets the video index in the playlist.
/// </summary>
[JsonProperty(PropertyName = "idx")]
public int Index { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the video has been already downloaded or not.
/// </summary>
[JsonProperty(PropertyName = "downloaded")]
public bool IsDownloaded { get; set; }
}
}
@@ -10,5 +10,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
/// Gets or sets the contained data object.
/// </summary>
public T? Data { get; set; }
/// <summary>
/// Gets or sets the pagination info object.
/// </summary>
public PaginationInfo? Paginate { get; set; }
}
}
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
using MediaBrowser.Model.Playlists;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -65,7 +67,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
/// <returns>A task.</returns>
public async Task<Channel?> GetChannel(string channelId)
{
ResponseContainer<Channel>? channel = null;
Channel? channel = null;
var channelsEndpoint = "/api/channel/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + channelsEndpoint + channelId));
@@ -73,19 +75,19 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
while (response.StatusCode == HttpStatusCode.Moved)
{
url = response.Headers.Location;
_logger.LogInformation("{Message}", "Received redirect to: " + url);
_logger.LogDebug("{Message}", "Received redirect to: " + url);
response = await client.GetAsync(url).ConfigureAwait(true);
}
_logger.LogInformation("{Message}", url + ": " + response.StatusCode);
_logger.LogDebug("{Message}", url + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
channel = JsonConvert.DeserializeObject<ResponseContainer<Channel>>(rawData);
channel = JsonConvert.DeserializeObject<Channel>(rawData);
}
return channel?.Data;
return channel;
}
/// <summary>
@@ -95,7 +97,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
/// <returns>A task.</returns>
public async Task<Video?> GetVideo(string videoId)
{
ResponseContainer<Video>? video = null;
Video? video = null;
var videosEndpoint = "/api/video/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + videosEndpoint + videoId));
@@ -103,19 +105,20 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
while (response.StatusCode == HttpStatusCode.Moved)
{
url = response.Headers.Location;
_logger.LogInformation("{Message}", "Received redirect to: " + url);
_logger.LogDebug("{Message}", "Received redirect to: " + url);
response = await client.GetAsync(url).ConfigureAwait(true);
}
_logger.LogInformation("{Message}", url + ": " + response.StatusCode);
_logger.LogDebug("{Message}", url + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
video = JsonConvert.DeserializeObject<ResponseContainer<Video>>(rawData);
_logger.LogDebug("{Message}", rawData);
video = JsonConvert.DeserializeObject<Video>(rawData);
}
return video?.Data;
return video;
}
/// <summary>
@@ -133,11 +136,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
while (response.StatusCode == HttpStatusCode.Moved)
{
url = response.Headers.Location;
_logger.LogInformation("{Message}", "Received redirect to: " + url);
_logger.LogDebug("{Message}", "Received redirect to: " + url);
response = await client.GetAsync(url).ConfigureAwait(true);
}
_logger.LogInformation("{Message}", url + ": " + response.StatusCode);
_logger.LogDebug("{Message}", url + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
@@ -149,7 +152,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
}
/// <summary>
/// Send video playback progress to TubeArchivist.
/// Sends video playback progress to TubeArchivist.
/// </summary>
/// <param name="videoId">Video id.</param>
/// <param name="progress">Progress in seconds.</param>
@@ -166,7 +169,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
}
/// <summary>
/// Send video playback progress to TubeArchivist.
/// Sends video playback progress to TubeArchivist.
/// </summary>
/// <param name="videoId">Video id.</param>
/// <returns>Video playback progress data.</returns>
@@ -174,22 +177,18 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
{
Progress? progress = null;
var progressEndpoint = $"/api/video/{videoId}/progress/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + progressEndpoint));
var response = await client.GetAsync(url).ConfigureAwait(true);
if (response.IsSuccessStatusCode)
var video = await GetVideo(videoId).ConfigureAwait(true);
if (video != null)
{
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
progress = JsonConvert.DeserializeObject<Progress>(rawData);
progress = new Progress((long)video.Player.Position);
_logger.LogDebug("{Message}", $"Retrieved progress {video.Player.Position}");
}
return progress;
}
/// <summary>
/// Set video/channel/playlist as watched on TubeArchivist.
/// Sets video/channel/playlist as watched on TubeArchivist.
/// </summary>
/// <param name="itemId">Video/channel/playlist id.</param>
/// <param name="isWatched">Whether the item has been watched or not.</param>
@@ -204,5 +203,137 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
return response.StatusCode;
}
/// <summary>
/// Retrieves the playlists from TubeArchivist.
/// </summary>
/// <returns>A task.</returns>
public async Task<ISet<Playlist>?> GetPlaylists()
{
ResponseContainer<ISet<Playlist>?>? playlists = null;
var playlistsEndpoint = "/api/playlist/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + playlistsEndpoint));
var response = await client.GetAsync(url).ConfigureAwait(true);
while (response.StatusCode == HttpStatusCode.Moved)
{
url = response.Headers.Location;
_logger.LogInformation("{Message}", "Received redirect to: " + url);
response = await client.GetAsync(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + url)).ConfigureAwait(true);
}
_logger.LogInformation("{Message}", url + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
playlists = JsonConvert.DeserializeObject<ResponseContainer<ISet<Playlist>?>>(rawData);
if (playlists?.Paginate != null)
{
var lastPage = playlists.Paginate.LastPage;
_logger.LogInformation("Pagination info: Current page {CurrentPage} / Last page {LastPage}, Total hits: {TotalHits}", playlists.Paginate.CurrentPage, playlists.Paginate.LastPage, playlists.Paginate.TotalHits);
while (playlists.Paginate.CurrentPage < lastPage)
{
var nextPage = playlists.Paginate.CurrentPage + 1;
var pagedUrl = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + playlistsEndpoint + "?page=" + nextPage));
response = await client.GetAsync(pagedUrl).ConfigureAwait(true);
while (response.StatusCode == HttpStatusCode.Moved)
{
url = response.Headers.Location;
_logger.LogInformation("{Message}", "Received redirect to: " + url);
response = await client.GetAsync(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + url)).ConfigureAwait(true);
}
_logger.LogInformation("{Message}", pagedUrl + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
var nextPagePlaylists = JsonConvert.DeserializeObject<ResponseContainer<ISet<Playlist>?>>(rawData);
if (nextPagePlaylists?.Data != null)
{
foreach (var playlist in nextPagePlaylists.Data)
{
playlists.Data?.Add(playlist);
}
}
if (nextPagePlaylists?.Paginate != null)
{
playlists.Paginate = nextPagePlaylists.Paginate;
_logger.LogInformation("Pagination info: Current page {CurrentPage} / Last page {LastPage}, Total hits: {TotalHits}", playlists.Paginate.CurrentPage, playlists.Paginate.LastPage, playlists.Paginate.TotalHits);
}
}
else
{
_logger.LogCritical("Failed to retrieve page {PageNumber} of playlists during pagination.", nextPage);
break;
}
}
}
}
return playlists?.Data;
}
/// <summary>
/// Creates a new custom playlist.
/// </summary>
/// <param name="creationRequest">Playlist creation request.</param>
/// <returns>The created <see cref="Playlist"/>.</returns>
public async Task<Playlist?> CreateCustomPlaylist(CustomPlaylistCreation creationRequest)
{
Playlist? playlist = null;
var customPlaylistEndpoint = $"/api/playlist/custom/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint));
var body = JsonConvert.SerializeObject(creationRequest);
_logger.LogInformation("{Message}", body);
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
_logger.LogInformation("POST {Message}", url + ": " + response.StatusCode);
if (response.IsSuccessStatusCode)
{
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
playlist = JsonConvert.DeserializeObject<Playlist>(rawData);
}
return playlist;
}
/// <summary>
/// Creates a new custom playlist.
/// </summary>
/// <param name="playlistId">Playlist id.</param>
/// <param name="entryAction">Playlist creation request.</param>
/// <returns>The response <see cref="HttpStatusCode"/>.</returns>
public async Task<HttpStatusCode> CustomPlaylistEntryAction(string playlistId, CustomPlaylistEntryAction entryAction)
{
var customPlaylistEndpoint = $"/api/playlist/custom/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint + playlistId));
var body = JsonConvert.SerializeObject(entryAction);
_logger.LogDebug("CustomPlaylistEntryAction body: {Message}", body);
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
_logger.LogDebug("Response code: {Message}", response.StatusCode);
return response.StatusCode;
}
/// <summary>
/// Deletes a playlist.
/// </summary>
/// <param name="playlistId">Playlist id.</param>
/// <returns>Whether the playlist has been deleted successfully or not.</returns>
public async Task<bool> DeletePlaylist(string playlistId)
{
var deletePlaylistEndpoint = $"/api/playlist/";
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + deletePlaylistEndpoint + playlistId));
var response = await client.DeleteAsync(url).ConfigureAwait(true);
_logger.LogDebug("Response code: {Message}", response.StatusCode);
return response.StatusCode == HttpStatusCode.NoContent;
}
}
}
@@ -1,4 +1,3 @@
using System;
using Newtonsoft.Json;
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
@@ -13,12 +12,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
/// </summary>
/// <param name="duration">Duration of the video.</param>
/// <param name="isWatched">Whether the video is marked as watched.</param>
/// <param name="watchedUnixTime">Unix time of when the video has been marked watched.</param>
public Player(long duration, bool isWatched, long watchedUnixTime)
/// <param name="position">Video watched seconds.</param>
public Player(long duration, bool isWatched, double position)
{
Duration = duration;
IsWatched = isWatched;
WatchedUnixTime = watchedUnixTime;
Position = position;
}
/// <summary>
@@ -34,18 +33,9 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
public bool IsWatched { get; }
/// <summary>
/// Gets the Unix date and time when the video has been marked as watched.
/// Gets the video watched seconds.
/// </summary>
[JsonProperty(PropertyName = "watched_date")]
public long WatchedUnixTime { get; }
/// <summary>
/// Gets the date and time when the video has been marked as watched.
/// </summary>
[JsonIgnore]
public DateTime WatchedTime
{
get => DateTimeOffset.FromUnixTimeSeconds(WatchedUnixTime).DateTime;
}
[JsonProperty(PropertyName = "position")]
public double Position { get; }
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
@@ -108,7 +109,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
Name = Title,
SearchProviderName = Constants.ProviderName,
ProductionYear = Published.Year,
ImageUrl = VidThumbUrl,
ImageUrl = Utils.HasImageUrl(VidThumbUrl) ? VidThumbUrl : null,
PremiereDate = Published,
ProviderIds = new Dictionary<string, string>() { { Constants.ProviderName, YoutubeId } }
};
@@ -126,6 +127,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
Overview = Utils.FormatDescription(Description),
SeasonName = Published.Year.ToString(CultureInfo.CurrentCulture),
ParentIndexNumber = Published.Year,
IndexNumber = Plugin.Instance?.Configuration?.EpisodeNumberingScheme switch
{
NumberingScheme.YYYYMMDD => (Published.Year * 10000) + (Published.Month * 100) + Published.Day,
_ => null
},
SeriesName = Channel.Name,
ProductionYear = Published.Year,
PremiereDate = Published,
@@ -136,14 +142,16 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
Constants.ProviderName, YoutubeId
}
},
ImageInfos = new[]
{
ImageInfos = Utils.HasImageUrl(VidThumbUrl) ?
[
new ItemImageInfo
{
Path = VidThumbUrl,
Type = ImageType.Primary
}
},
] :
[],
Tags = this.Tags.ToArray<string>()
};
}
@@ -1,6 +1,7 @@
using System.IO;
using System;
using System.Linq;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
{
@@ -9,38 +10,86 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
/// </summary>
public static class Utils
{
private const string TAPlaylistIdRegex = @"^(.*)\((.*)\)$";
private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$";
private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$";
/// <summary>
/// Sanitize the given URL.
/// Sanitizes the given URL.
/// </summary>
/// <param name="inputUrl">An URL string.</param>
/// <returns>The URL string without spaces, doubled slashes and with a trailing slash.</returns>
public static string SanitizeUrl(string inputUrl)
{
// Extract the schema part
Match schemaMatch = Regex.Match(inputUrl, @"^(?<schema>https?://)");
if (string.IsNullOrWhiteSpace(inputUrl))
{
return string.Empty;
}
// Remove double slashes and spaces from the remaining part of the URL
string cleanedPath = Regex.Replace(inputUrl.Substring(schemaMatch.Length), @"[/\s]+", "/");
// Extract the schema part (http:// or https://)
Match schemaMatch = Regex.Match(inputUrl, @"^(?<schema>https?://)", RegexOptions.IgnoreCase);
// If no schema found, treat whole string as the rest
int schemaLength = schemaMatch.Success ? schemaMatch.Length : 0;
// Separate the main part from query (?) and fragment (#)
string rest = inputUrl.Substring(schemaLength);
string pathPart = rest;
string queryAndFragment = string.Empty;
int qIndex = rest.IndexOf('?', StringComparison.Ordinal);
int fIndex = rest.IndexOf('#', StringComparison.Ordinal);
int splitIndex = -1;
if (qIndex >= 0 && fIndex >= 0)
{
splitIndex = Math.Min(qIndex, fIndex);
}
else if (qIndex >= 0)
{
splitIndex = qIndex;
}
else if (fIndex >= 0)
{
splitIndex = fIndex;
}
if (splitIndex >= 0)
{
pathPart = rest.Substring(0, splitIndex);
queryAndFragment = rest.Substring(splitIndex);
}
// Remove double slashes and spaces from the path part
string cleanedPath = Regex.Replace(pathPart, @"[/\s]+", "/");
// Remove slashes at the start
cleanedPath = cleanedPath.TrimStart('/');
// Add a trailing slash if not already present
cleanedPath = cleanedPath.TrimEnd('/') + "/";
// Add a trailing slash only when there are no query or fragment parts
if (string.IsNullOrEmpty(queryAndFragment))
{
cleanedPath = cleanedPath.TrimEnd('/') + "/";
}
// Combine the schema and cleaned path
string cleanedUrl = schemaMatch.Groups["schema"].Value + cleanedPath;
// Combine the schema and cleaned path and re-append query/fragment
string cleanedUrl = (schemaMatch.Success ? schemaMatch.Groups["schema"].Value : string.Empty) + cleanedPath + queryAndFragment;
return cleanedUrl;
}
/// <summary>
/// Format episodes and series descriptions replacing newlines with br tags.
/// Formats episodes and series descriptions replacing newlines with br tags.
/// </summary>
/// <param name="description">String to format.</param>
/// <returns>A string with \n replaced by br tags.</returns>
public static string FormatDescription(string description)
{
if (description == null)
{
return string.Empty;
}
var maxLength = 500;
if (Plugin.Instance != null)
{
@@ -50,14 +99,15 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
if (description.Length > maxLength)
{
description = description.Substring(0, maxLength);
description = description.Replace("\n", "<br>", System.StringComparison.CurrentCulture);
}
description = description.Replace("\n", "<br>", System.StringComparison.CurrentCulture);
return description;
}
/// <summary>
/// Get video name from file path on the disk.
/// Gets video name from file path on the disk.
/// </summary>
/// <param name="path">File path on disk.</param>
/// <returns>The video name.</returns>
@@ -67,7 +117,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <summary>
/// Get channel name from directory path on the disk.
/// Gets channel name from directory path on the disk.
/// </summary>
/// <param name="path">Directory path on disk.</param>
/// <returns>The channel name.</returns>
@@ -76,6 +126,32 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
return path.Split(DetectDirectorySeparator(path)).Last();
}
/// <summary>
/// Checks whether the Jellyfin item belongs to the configured TubeArchivist collection.
/// </summary>
/// <param name="item">Jellyfin item.</param>
/// <returns>True when the item belongs to the configured TubeArchivist collection.</returns>
public static bool IsTubeArchivistItem(BaseItem item)
{
if (Plugin.Instance == null)
{
return false;
}
var topParent = item.GetTopParent();
return string.Equals(topParent?.Name, Plugin.Instance.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks whether a TubeArchivist image URL can be fetched safely.
/// </summary>
/// <param name="url">Image URL returned by TubeArchivist.</param>
/// <returns>True when the image URL is non-empty.</returns>
public static bool HasImageUrl(string? url)
{
return !string.IsNullOrWhiteSpace(url);
}
private static char DetectDirectorySeparator(string path)
{
int backslashCount = path.Count(c => c == '\\');
@@ -90,5 +166,35 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
return '/'; // Unix directory separator
}
}
/// <summary>
/// Gets the TubeArchivist playlist id from Jellyfin playlist name.
/// </summary>
/// <param name="playlistName">The Jellyfin playlist name.</param>
/// <returns>The TubeArchvist playlist id.</returns>
public static string? GetTAPlaylistIdFromName(string playlistName)
{
var regex = new Regex(TAPlaylistIdRegex);
return regex.Match(playlistName).Groups[2].ToString();
}
/// <summary>
/// Gets the TubeArchivist playlist name from Jellyfin playlist name.
/// </summary>
/// <param name="playlistName">The Jellyfin playlist name.</param>
/// <returns>The TubeArchivist playlist name.</returns>
public static string? GetTAPlaylistNameFromName(string playlistName)
{
var ytRegex = new Regex(YTTAPlaylistNameFormatRegex);
var regex = new Regex(TAPlaylistNameFormatRegex);
var name = ytRegex.Match(playlistName).Groups[1].ToString();
if (string.IsNullOrEmpty(name))
{
name = regex.Match(playlistName).Groups[1].ToString();
}
return name;
}
}
}
+59 -20
View File
@@ -1,12 +1,17 @@
<h1 align="center">Jellyfin TubeArchivist Plugin</h1>
<p align="center">
<img alt="Plugin Banner" src="https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png"/>
<img alt="Plugin Banner" src="https://git.felixfoertsch.de/felixfoertsch/tubearchivist-jf-plugin/raw/branch/main/images/logo.png"/>
<br/>
</p>
> [!IMPORTANT]
> Jellyfin 10.9.1 has been finally released and since starting with the version 1.3.0 the plugin supports only the latest Jellyfin release, in order to continue using this plugin with all the latest features you will need to upgrade your Jellyfin installation.
> Jellyfin release cycle has changed in the past few months and now it is shorter than before. The plugin supports only the latest Jellyfin release, in order to continue using this plugin with all the latest features you will need to upgrade your Jellyfin installation.
> The same rule applies to TubeArchivist: the plugin is only guaranteed to work with the latest TubeArchivist version.
> [!WARNING]
> Jellyfin 10.10 introduced a bug that prevents the plugin to correctly create Season folders by year. The bug has been finally solved on the Jellyfin codebase, but, until the next minor release, a manual build of the Jellyfin branch `release-10.10.z` is required in order to get the fix running.<br>
> This is not a plugin bug, any issue opened about this bug will be immediately closed!
## About
@@ -22,18 +27,23 @@ The plugin interacts with TubeArchivist APIs to fetch videos and channels metada
- Add images for videos (episodes), ie. thumb images
- Add images for channels (shows), ie. thumb, tvart and banner images
- Organize videos (episodes) by year (seasons)
- Bidirectional playback progress synchronization
- Bidirectional playlists synchronization
> [!WARNING]
> Enabling synchronization in both directions you can run in race conditions and unexpected results.
## Installation
### From official repository (recommended)
1. Go to `Dashboard -> Plugins` and select the `Repositories` tab
1. Go to `Dashboard -> Plugins` and click on the `Manage Repositories` button
2. Add a new repository with the following details:
- Repository name: `TubeArchivistMetadata`
- Repository URL: `https://github.com/tubearchivist/tubearchivist-jf-plugin/raw/master/manifest.json`
![Add repository](https://github.com/tubearchivist/tubearchivist-jf-plugin/assets/31162436/b0216b21-79c4-445b-8cf1-fbf6b138dee0)
- Repository URL: `https://git.felixfoertsch.de/felixfoertsch/tubearchivist-jf-plugin/raw/branch/main/manifest.json`
![Add repository](https://github.com/user-attachments/assets/337ba921-bc97-47ea-815c-c664cf7661a2)
3. Go to the `Catalog` tab
4. Find `TubeArchivistMetadata` in the `Metadata` section and install it
![Find plugin](https://github.com/tubearchivist/tubearchivist-jf-plugin/assets/31162436/a30a14c2-33cd-44c1-b96a-406662726e5e)
3. Go back to the catalog
4. Find `TubeArchivistMetadata` and install it
![Find plugin](https://github.com/user-attachments/assets/41f7315b-27c6-47dd-958f-21a232c30013)
5. Restart Jellyfin to apply the changes
### From ZIP in GitHub releases
@@ -67,28 +77,57 @@ _NOTE: If you are using Docker containers, it is important to mount the TubeArch
5. Save and come back to Home, you will see the newly added library. Jellyfin will have executed the metadata fetching for you after the collection creation and then you will see the metadata and the images of channels and videos
## Playback synchronization
<p>Starting from v1.3.1 this plugin offers playback progress and watched status bidirectional synchronization, but you can choose to enable only a one way synchronization (Jellyfin->TubeArchivist or TubeArchivist->Jellyfin) too.</p>
## Synchronization
This plugin has different bidirectional sycnhronization features, that can be configured in the specific section in the plugin configuration page:
![Synchronization settings](https://github.com/user-attachments/assets/b0bb556b-fce3-4a3e-bc6c-b0a2b482cedc)
### Jellyfin->TubeArchivist synchronization
### Playback synchronization
<p>Starting from v1.3.1 this plugin offers playback progress and watched status bidirectional synchronization, but you can choose to enable only a one way synchronization (Jellyfin -> TubeArchivist or TubeArchivist -> Jellyfin) too.</p>
#### Jellyfin -> TubeArchivist playback synchronization
<p>This kind of synchronization is done listening for progress and watched status changes while playing the videos for the specified users.<br>Furthermore, there is a task that runs at Jellyfin startup to synchronize the whole library.</p>
<p>In the plugin configuration you will find these settings:</p>
![JF->TA playback synchronization settings](https://github.com/user-attachments/assets/dc6be82f-e685-4896-a502-317681c47fc7)
<p>In the text field you can specify one Jellyfin username to synchronize data of to TubeArchivist.</p>
### TubeArchivist->Jellyfin synchronization
#### TubeArchivist -> Jellyfin playback synchronization
<p>This kind of synchronization is done using a Jellyfin scheduled task that regularly synchronizes data from TubeArchivist API to Jellyfin.</p>
<p>In the plugin configuration you will find these settings:</p>
<p>In the text field you can specify one or more Jellyfin usernames to update data for.</p>
![TA->JF playback synchronization settings](https://github.com/user-attachments/assets/1b4c33af-834f-45b3-9057-71830e7c8b4f)
<p>In the first text field you can specify one or more Jellyfin usernames to update data for.<br>
In the second field you can specify the interval in seconds the task should run at, so that you can choose according to your system requirements. The lower is the interval the higher will be the resources consuption on your system.</p>
### Playlists synchronization
<p>Starting from v.1.4.1 this plugin offers playlists bidirectional synchronization, but you can choose to enable only a one way synchronization (Jellyfin -> TubeArchivist or TubeArchivist -> Jellyfin) too.</p>
#### Jellyfin -> TubeArchivist playlists synchronization
<p>There is a task that retrieves playlists and recreates them on TubeArchivist with the videos in the same order. Please note that playlists can also have videos not beloging from TubeArchivist, they will be simply ignored, so you won't find them on TubeArchivist playlist.</p>
<p>It is present also a setting to automatically delete playlists from TubeArchivist when they are no more available on Jellyfin.</p>
#### TubeArchivist -> Jellyfin playlists synchronization
<p>There is a task that retrieves playlists from TubeArchivist and recreates them on Jellyfin with videos in the same order.</p>
<p>It is present, also in this case, a setting to automatically delete playlists from Jellyfin when they are no more present on TubeArchivist, but beware that the will be deleted also if they contain videos not beloning to TubeArchivist.</p>
> [!CAUTION]
> Pay attention when you enable the automatic deletion options, be sure that is your wanted behavior, especially when playlists contain also other videos not belonging from TubeArchivist, playlists removed won't be available again, there's no undo!
## Tasks intervals
<p>Since many of the feature are implemented as background tasks periodically executing, in the `Tasks intervals` section you will find the settings to adjust this period in seconds.<br>
Keep in mind that Jellyfin lowest accepted period is of 1 minute (60 seconds) and the lower is the interval the higher will be the resources consuption on your system.</p>
<p>Here are the configurable intervals:</p>
![Tasks intervals settings](https://github.com/user-attachments/assets/19db6b83-6715-477d-8ce7-b78526e87ba9)
## Episode numbering
<p>There are different ways to number the episodes as they are configured in Jellyfin.<br>
This changes the number after E in S--E-- (for example S2024E100 for episode number 100 of season 2024).</p>
![Episode numbering scheme options](https://github.com/user-attachments/assets/6d36bc2c-ca9d-4a5c-8021-e15d399316fc)
The options correlate with:
- Default - leave the numbering to what Jellyfin does by default (this is what the plugin has always done)
- YYYYMMDD - numbers the episode by the year, month, day (e.g. 20250804 for a video published on the 4th of August 2025)
## Build
1. To build this plugin you will need [.Net 8.x](https://dotnet.microsoft.com/download/dotnet/8.0).
1. To build this plugin you will need [.Net 9.x](https://dotnet.microsoft.com/download/dotnet/9.0).
2. Build plugin with following command
```
+5 -6
View File
@@ -1,10 +1,10 @@
---
name: "TubeArchivistMetadata"
guid: "dc97d0c6-28b0-4242-afb4-5833ae1b3715"
imageUrl: https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png
version: "1.3.2.0"
targetAbi: "10.9.1.0"
framework: "net8.0"
imageUrl: https://git.felixfoertsch.de/felixfoertsch/tubearchivist-jf-plugin/raw/branch/main/images/logo.png
version: "1.4.5.0"
targetAbi: "10.11.0.0"
framework: "net9.0"
overview: "Metadata for your TubeArchivist library on Jellyfin"
description: >
TubeArchivistMetadata is a plugin that automatically manages
@@ -14,5 +14,4 @@ owner: "DarkFighterLuke"
artifacts:
- "Jellyfin.Plugin.TubeArchivistMetadata.dll"
changelog: >
Add tasks progress status
Adjust logs levels
Limit providers to TubeArchivist paths, skip empty image URLs, and fix playlist sync intervals
+101 -13
View File
@@ -6,15 +6,103 @@
"description": "TubeArchivistMetadata is a plugin that automatically manages metadata for videos downloaded with TubeArchivist from YouTube.\n",
"owner": "DarkFighterLuke",
"overview": "Metadata for your TubeArchivist library on Jellyfin",
"imageUrl": "https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png",
"imageUrl": "https://git.felixfoertsch.de/felixfoertsch/tubearchivist-jf-plugin/raw/branch/main/images/logo.png",
"versions": [
{
"version": "1.4.5.0",
"changelog": "Limit providers to TubeArchivist paths, skip empty image URLs, and fix playlist sync intervals\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.felixfoertsch.de/felixfoertsch/tubearchivist-jf-plugin/releases/download/v1.4.5/tubearchivistmetadata_1.4.5.0.zip",
"checksum": "2b3c47c6468fac2c6e42a005f66630c6",
"timestamp": "2026-07-01T06:20:00Z"
},
{
"version": "1.4.4.0",
"changelog": "Handle TubeArchivist playlists pagination\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.4/tubearchivistmetadata_1.4.4.0.zip",
"checksum": "90f1d388f7363537df8287e996a96bbd",
"timestamp": "2025-12-02T13:40:47Z"
},
{
"version": "1.4.3.0",
"changelog": "Introduce episode numbering alternative schema\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.3/tubearchivistmetadata_1.4.3.0.zip",
"checksum": "297d4f15af1132939f43c905b1ad393e",
"timestamp": "2025-11-13T19:31:49Z"
},
{
"version": "1.4.2.0",
"changelog": "Introduce episode numbering alternative schema\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.2/tubearchivistmetadata_1.4.2.0.zip",
"checksum": "8df142d2f1f0c0dffa302a567b40ef84",
"timestamp": "2025-10-23T20:27:32Z"
},
{
"version": "1.4.1.0",
"changelog": "Introduce bidirectional playlists synchronization\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.1/tubearchivistmetadata_1.4.1.0.zip",
"checksum": "2dc0b9140472a4f783f093a7ee425493",
"timestamp": "2025-10-22T07:00:27Z"
},
{
"version": "1.4.0.0",
"changelog": "Introduce bidirectional playlists synchronization\n",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.0/tubearchivistmetadata_1.4.0.0.zip",
"checksum": "4b81ab60022b532a64bb5eeb7be67dcc",
"timestamp": "2025-10-22T06:57:53Z"
},
{
"version": "1.3.7.0",
"changelog": "Fix JF->TA Sync\n",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.7/tubearchivistmetadata_1.3.7.0.zip",
"checksum": "60d8e9f134912f907dccd30b531da4f6",
"timestamp": "2025-05-27T18:36:55Z"
},
{
"version": "1.3.6.0",
"changelog": "Fix TA auth header\n",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.6/tubearchivistmetadata_1.3.6.0.zip",
"checksum": "513cde64268867d0047009eb2d986054",
"timestamp": "2025-03-10T22:01:13Z"
},
{
"version": "1.3.5.0",
"changelog": "Adapt to the new TA API changes Fix JF crash when setting watched status with TA offline\n",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.5/tubearchivistmetadata_1.3.5.0.zip",
"checksum": "2536622c0ec73e6401860929f24849f7",
"timestamp": "2025-03-10T17:47:43Z"
},
{
"version": "1.3.4.0",
"changelog": "Merge workaround for JF crashing when scanning Series pull request by wolffshots\n",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.4/tubearchivistmetadata_1.3.4.0.zip",
"checksum": "1ee6041139a6fdc3cecc8b2a427c60a8",
"timestamp": "2024-11-04T21:30:24Z"
},
{
"version": "1.3.3.0",
"changelog": "Add compatibility with Jellyfin 10.10\n",
"targetAbi": "10.10.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.3/tubearchivistmetadata_1.3.3.0.zip",
"checksum": "ee6431d8bdaf844672d167b6f3ee0a73",
"timestamp": "2024-11-04T20:41:46Z"
},
{
"version": "1.3.2.0",
"changelog": "Add support for Jellyfin 10.9.1\n",
"changelog": "Add tasks progress status Adjust logs levels\n",
"targetAbi": "10.9.1.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.2/tubearchivistmetadata_1.3.2.0.zip",
"checksum": "90e3bc35cdadf8688813252e6d5c390a",
"timestamp": "2024-09-14T13:33:07Z"
"checksum": "6162235807535a5c2a7570276798e69e",
"timestamp": "2024-09-14T13:41:58Z"
},
{
"version": "1.3.1.0",
@@ -46,7 +134,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.4/tubearchivistmetadata_1.2.4.0.zip",
"checksum": "b418a46d458fe28354580d70748b1ced",
"timestamp": "2024-05-09 15:41:00"
"timestamp": "2024-05-09T15:41:00Z"
},
{
"version": "1.2.3.0",
@@ -54,7 +142,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.3/tubearchivistmetadata_1.2.3.0.zip",
"checksum": "f5328a662444739eaa7c59855bfcb203",
"timestamp": "2024-04-18 21:45:00"
"timestamp": "2024-04-18T21:45:00Z"
},
{
"version": "1.2.2.0",
@@ -62,7 +150,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.2/tubearchivistmetadata_1.2.2.0.zip",
"checksum": "82d04546916e1ba76d56de28e648447a",
"timestamp": "2024-03-15 10:12:00"
"timestamp": "2024-03-15T10:12:00Z"
},
{
"version": "1.2.1.0",
@@ -70,7 +158,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.1/tubearchivistmetadata_1.2.1.0.zip",
"checksum": "cf68d169956f166a6ee68a06192651c2",
"timestamp": "2024-03-14 21:50:00"
"timestamp": "2024-03-14T21:50:00Z"
},
{
"version": "1.2.0.0",
@@ -78,7 +166,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.0/tubearchivistmetadata_1.2.0.0.zip",
"checksum": "75812a4464bf68c43fb9dcdb74d924e3",
"timestamp": "2024-03-14 20:00:00"
"timestamp": "2024-03-14T20:00:00Z"
},
{
"version": "1.1.1.0",
@@ -86,7 +174,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.1.1/tubearchivistmetadata_1.1.1.0.zip",
"checksum": "84ce752ce662e834690512c88ba48664",
"timestamp": "2024-03-14 16:35:00"
"timestamp": "2024-03-14T16:35:00Z"
},
{
"version": "1.1.0.0",
@@ -94,7 +182,7 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.1.0/tubearchivistmetadata_1.1.0.0.zip",
"checksum": "de4b6562973b7f75b6afe3a441bb0e19",
"timestamp": "2024-03-13 15:02:00"
"timestamp": "2024-03-13T15:02:00Z"
},
{
"version": "1.0.0.0",
@@ -102,8 +190,8 @@
"targetAbi": "10.8.0.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.0.0/tubearchivistmetadata_1.0.0.0.zip",
"checksum": "444f8980671de494b1875ee7d1657bcf",
"timestamp": "2024-03-11 16:02:00"
"timestamp": "2024-03-11T16:02:00Z"
}
]
}
]
]
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
# Description: Build a deterministic Jellyfin plugin release zip.
# Usage: ./scripts/package-release.sh
main() {
local root
root="$(git rev-parse --show-toplevel)"
cd "$root"
local version
version="$(awk -F'\"' '/^version:/ { print $2 }' build.yaml)"
local dll_path
dll_path="Jellyfin.Plugin.TubeArchivistMetadata/bin/Release/net9.0/Jellyfin.Plugin.TubeArchivistMetadata.dll"
if [[ ! -f "$dll_path" ]]; then
echo "Missing release DLL: $dll_path" >&2
exit 1
fi
local output_dir
output_dir="artifacts"
mkdir -p "$output_dir"
local zip_path
zip_path="$output_dir/tubearchivistmetadata_${version}.zip"
rm -f "$zip_path"
local package_dir
package_dir="$(mktemp -d)"
trap 'rm -rf "'"$package_dir"'"' EXIT
cp "$dll_path" "$package_dir/Jellyfin.Plugin.TubeArchivistMetadata.dll"
TZ=UTC touch -t 198001010000 "$package_dir/Jellyfin.Plugin.TubeArchivistMetadata.dll"
(
cd "$package_dir"
TZ=UTC zip -X -q "$root/$zip_path" Jellyfin.Plugin.TubeArchivistMetadata.dll
)
local entry_count
entry_count="$(zipinfo -1 "$zip_path" | wc -l | tr -d ' ')"
if [[ "$entry_count" != "1" ]]; then
echo "Unexpected zip entry count: $entry_count" >&2
zipinfo -1 "$zip_path" >&2
exit 1
fi
local entry_name
entry_name="$(zipinfo -1 "$zip_path")"
if [[ "$entry_name" != "Jellyfin.Plugin.TubeArchivistMetadata.dll" ]]; then
echo "Unexpected zip entry: $entry_name" >&2
exit 1
fi
md5sum "$zip_path" | cut -d' ' -f1
}
main "$@"