Compare commits

...

90 Commits

Author SHA1 Message Date
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
DarkFighterLuke bdd99441d1 Fix #52 2025-07-16 22:58:44 +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 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
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
DarkFighterLuke a8f821625b Update version 2024-09-14 15:41:00 +02:00
DarkFighterLuke 6cb2d89b95 Merge branch 'master' of github.com:tubearchivist/tubearchivist-jf-plugin 2024-09-14 15:36:40 +02:00
DarkFighterLuke 080f3856b7 Update changelog 2024-09-14 15:36:30 +02:00
DarkFighterLuke b436ae13a1 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2024-09-14 13:33:10 +00:00
DarkFighterLuke dce122840c Adjust logs levels 2024-09-14 15:31:19 +02:00
DarkFighterLuke 9129bbda71 Report tasks progress
modified:   Jellyfin.Plugin.TubeArchivistMetadata/Tasks/JFToTubeArchivistProgressSyncTask.cs
	modified:   Jellyfin.Plugin.TubeArchivistMetadata/Tasks/TAToJellyfinProgressSyncTask.cs
2024-09-14 15:25:13 +02:00
DarkFighterLuke 698237a77a Merge pull request #18 from IceToast/master
feat: Add backdrop image to series
2024-09-13 15:11:57 +02:00
DarkFighterLuke 0bb2cb1108 Update README.md
Add playback progress synchronization info
2024-09-12 16:10:00 +02:00
DarkFighterLuke 286d43c840 Update manifest.json
Signed-off-by: DarkFighterLuke <DarkFighterLuke@users.noreply.github.com>
2024-09-12 13:36:15 +00:00
IceToast 6675f7f762 feat: Add backdrop image to series 2024-07-15 21:47:06 +02:00
28 changed files with 1644 additions and 228 deletions
+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
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- name: Setup .Net
uses: actions/setup-dotnet@v1
with:
dotnet-version: 8.0.x
dotnet-version: 9.0.x
- name: Add Jellyfin GitHub Nuget registry to sources
run: |
@@ -63,7 +63,7 @@ jobs:
uses: DarkFighterLuke/jellyfin-plugin-repository-manager@7a96768accc155ac7b597351b5033532f3c48173
id: jprm
with:
dotnet-target: net8.0
dotnet-target: net9.0
version: ${VERSION}
update-manifest: "true"
+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,
}
+3 -3
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<Version>1.3.0.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
<Version>1.4.1.0</Version>
<AssemblyVersion>1.4.1.0</AssemblyVersion>
<FileVersion>1.4.1.0</FileVersion>
</PropertyGroup>
</Project>
@@ -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.
@@ -124,8 +151,20 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
set
{
value.Replace(" ", string.Empty, StringComparison.CurrentCulture).Split(',').ToList().ForEach(u => _jfUsernamesTo.Add(u));
_logger.LogInformation("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,19 @@ 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 the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist.
@@ -42,28 +42,43 @@
and episodes</div>
</div>
<div>
<h2>Playback synchronization</h2>
<h2>Synchronization</h2>
</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>
<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 +87,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 +154,15 @@
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('#TAJFProgressTaskInterval').value = config.TAJFProgressTaskInterval;
document.querySelector('#JFTAPlaylistsSyncTaskInterval').value = config.JFTAPlaylistsSyncTaskInterval;
document.querySelector('#TAJFPlaylistsSyncTaskInterval').value = config.TAJFPlaylistsSyncTaskInterval;
Dashboard.hideLoadingMsg();
});
});
@@ -117,11 +175,15 @@
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.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>
+91 -39
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,9 +156,16 @@ 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);
Logger.LogDebug("Skipping progress synchronization: PlaybackProgress event triggered with null or empty Guid.");
return;
}
if (Instance!.Configuration.JFTAProgressSync && eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)))
{
BaseItem? season = LibraryManager.GetItemById(eventArgs.Item.ParentId);
BaseItem? channel = LibraryManager.GetItemById(season!.ParentId);
BaseItem? collection = LibraryManager.GetItemById(channel!.ParentId);
if (collection?.Name.ToLower(CultureInfo.CurrentCulture) == Instance?.Configuration.CollectionTitle.ToLower(CultureInfo.CurrentCulture) && eventArgs.PlaybackPositionTicks != null)
{
@@ -164,7 +174,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
var statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
Logger.LogInformation("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
}
}
}
@@ -172,29 +182,71 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
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.LogInformation("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
Logger.LogDebug("Skipping watched status synchronization: WatchedStatusChange event triggered with null or empty Guid.");
return;
}
var user = _userManager.GetUserById(eventArgs.UserId);
if (user == null)
{
Logger.LogError("OnWatchedStatusChange callback called without user id for item {ItemName}", eventArgs.Item.Name);
return;
}
var userItemData = _userDataManager.GetUserData(user, eventArgs.Item);
if (Configuration.JFTAProgressSync && user != null && Configuration.GetJFUsernamesToArray().Contains(user!.Username))
{
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.LogInformation("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);
}
}
}
@@ -62,8 +62,8 @@ 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)
{
@@ -53,8 +53,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
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)
{
@@ -60,10 +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);
_logger.LogDebug("{Message}", "TVArt URI: " + channel?.TvartUrl);
_logger.LogDebug("{Message}", "Banner URI: " + channel?.BannerUrl);
if (channel != null)
{
@@ -85,6 +85,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
Type = ImageType.Banner,
Url = channel.BannerUrl
});
list.Add(new RemoteImageInfo
{
ProviderName = Name,
Type = ImageType.Backdrop,
Url = channel.TvartUrl
});
}
return list;
@@ -52,8 +52,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
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)
{
@@ -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.TAJFProgressTaskInterval).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";
@@ -56,96 +57,148 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (Plugin.Instance!.Configuration.JFTASync)
progress.Report(0);
if (Plugin.Instance!.Configuration.JFTAProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting Jellyfin->TubeArchivist playback progresses synchronization.");
var taApi = TubeArchivistApi.GetInstance();
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
var videosCount = 0;
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.LogInformation("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 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 }
});
_logger.LogInformation("Found {Years} years in channel {ChannelName}", years.Count, channel.Name);
_logger.LogDebug("Found {Videos} videos in year {YearName} of the channel {ChannelName}", videos.Count, year.Name, channel.Name);
videosCount += videos.Count;
}
}
}
foreach (Season year in years)
_logger.LogDebug("Found a total of {VideosCount} videos", videosCount);
var processedVideosCount = 0;
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
{
IncludeItemTypes = new[] { BaseItemKind.Series }
});
foreach (Series channel in channels)
{
var channelYTId = Utils.GetChannelNameFromPath(channel.Path);
var isChannelWatched = false;
var isChannelCheckedForWatched = false;
var years = channel.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Season }
});
foreach (Season year in years)
{
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
var videos = year.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
_logger.LogInformation("Found {Videos} videos in year {YearName} of the channel {ChannelName}", videos.Count, year.Name, channel.Name);
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
videosCount += videos.Count;
foreach (Episode video in videos)
foreach (Episode video in videos)
{
var videoYTId = Utils.GetVideoNameFromPath(video.Path);
_logger.LogDebug("{VideoYtId}", videoYTId);
HttpStatusCode statusCode;
var userItemData = _userDataManager.GetUserData(user, channel);
if (!isChannelCheckedForWatched && channel.IsPlayed(user, userItemData))
{
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, userItemData);
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogInformation("{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}");
}
else
{
isChannelWatched = true;
}
if (!isChannelCheckedForWatched && channel.IsPlayed(user))
{
var isChannelPlayed = channel.IsPlayed(user);
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
{
_logger.LogInformation("{Message}", $"POST /watched returned {statusCode} for channel {channel.Name} ({channelYTId}) with wacthed status {isChannelPlayed}");
}
else
{
isChannelWatched = true;
}
isChannelCheckedForWatched = true;
}
isChannelCheckedForWatched = true;
if (!isChannelWatched)
{
var isVideoPlayed = video.IsPlayed(user, userItemData);
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}");
}
if (!isChannelWatched)
_logger.LogDebug("{Message}", isVideoPlayed);
if (!isVideoPlayed)
{
var isVideoPlayed = video.IsPlayed(user);
statusCode = await taApi.SetWatchedStatus(videoYTId, isVideoPlayed).ConfigureAwait(true);
if (statusCode != System.Net.HttpStatusCode.OK)
var playbackProgress = _userDataManager.GetUserData(user, video)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
if (playbackProgress != null)
{
_logger.LogInformation("{Message}", $"POST /watched returned {statusCode} for video {video.Name} ({videoYTId}) with wacthed status {isVideoPlayed}");
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");
}
}
}
}
processedVideosCount++;
progress.Report(processedVideosCount * 100 / videosCount);
}
}
}
@@ -157,6 +210,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
_logger.LogInformation("Jellyfin->TubeArchivist playback synchronization is currently disabled.");
}
progress.Report(100);
}
/// <inheritdoc/>
@@ -166,7 +221,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
[
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerStartup,
Type = TaskTriggerInfoType.StartupTrigger,
},
];
}
@@ -0,0 +1,201 @@
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;
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.TAJFProgressTaskInterval).Ticks
},
];
}
}
}
@@ -56,11 +56,13 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
/// <inheritdoc/>
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
if (Plugin.Instance!.Configuration.TAJFSync)
progress.Report(0);
if (Plugin.Instance!.Configuration.TAJFProgressSync)
{
var start = DateTime.Now;
_logger.LogInformation("Starting TubeArchivist->Jellyfin playback progresses synchronization.");
var taApi = TubeArchivistApi.GetInstance();
var videosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
{
var user = _userManager.GetUserByName(jfUsername);
@@ -70,11 +72,70 @@ 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)
{
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
{
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 years = channel.GetChildren(user, false, new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Season }
});
_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 a total of {VideosCount} videos", videosCount);
var processedVideosCount = 0;
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
{
var user = _userManager.GetUserByName(jfUsername);
if (user == null)
{
_logger.LogDebug("{Message}", $"Jellyfin user with username {jfUsername} not found");
continue;
}
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
Name = Plugin.Instance?.Configuration.CollectionTitle,
IncludeItemTypes = new[] { BaseItemKind.CollectionFolder }
});
var collectionItem = items.Count > 0 ? items[0] : null;
if (collectionItem == null)
{
@@ -88,8 +149,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
IncludeItemTypes = new[] { BaseItemKind.Series }
});
_logger.LogInformation("Analyzing collection {Id} with name {Name}", collectionItem.Id, collectionItem.Name);
_logger.LogInformation("Found {Message} channels", channels.Count);
foreach (Series channel in channels)
{
@@ -97,7 +156,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
IncludeItemTypes = new[] { BaseItemKind.Season }
});
_logger.LogInformation("Found {Years} years in channel {ChannelName}", years.Count, channel.Name);
foreach (Season year in years)
{
@@ -105,7 +163,6 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
IncludeItemTypes = new[] { BaseItemKind.Episode }
});
_logger.LogInformation("Found {Videos} videos in year {YearName} of the channel {ChannelName}", videos.Count, year.Name, channel.Name);
foreach (Episode video in videos)
{
@@ -132,8 +189,11 @@ 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);
}
}
}
@@ -147,6 +207,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
{
_logger.LogInformation("TubeArchivist->Jellyfin playback synchronization is currently disabled.");
}
progress.Report(100);
}
/// <inheritdoc/>
@@ -156,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
},
];
}
@@ -126,7 +126,9 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
Type = ImageType.Primary
}
},
Tags = this.Tags.ToArray<string>()
Tags = this.Tags != null ?
this.Tags.ToArray<string>() :
[]
};
}
}
@@ -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,116 @@
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; }
}
}
@@ -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; }
}
}
@@ -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,93 @@ 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(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);
}
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; }
}
}
@@ -9,8 +9,12 @@ 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>
@@ -35,7 +39,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <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>
@@ -57,7 +61,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
}
/// <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 +71,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>
@@ -90,5 +94,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;
}
}
}
+38 -7
View File
@@ -6,7 +6,12 @@
</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)
![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
@@ -49,6 +59,7 @@ Once installed, you have to configure the following parameters in the plugin con
<li>TubeArchivist instance address</li>
<li>TubeArchivist API key</li>
<li>Overviews length (channels and videos descriptions)</li>
<li>Playback synchronization settings discussed in the <a href="#playback-synchronization">Playback synchronization</a> paragraph</li>
</ul>
![Plugin configuration](https://github.com/tubearchivist/tubearchivist-jf-plugin/assets/31162436/d34464ea-ddfb-44b3-9d3e-5d5974956c58)
@@ -65,9 +76,29 @@ _NOTE: If you are using Docker containers, it is important to mount the TubeArch
4. Scrolling down, uncheck all metadata and image providers except `TubeArchivist`. (You won't find TubeArchivist in seasons providers, so just disable everything there)
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>
### Jellyfin->TubeArchivist 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
<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>
![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>
## 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
```
+4 -4
View File
@@ -2,9 +2,9 @@
name: "TubeArchivistMetadata"
guid: "dc97d0c6-28b0-4242-afb4-5833ae1b3715"
imageUrl: https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png
version: "1.3.0.0"
targetAbi: "10.9.1.0"
framework: "net8.0"
version: "1.4.1.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,4 +14,4 @@ owner: "DarkFighterLuke"
artifacts:
- "Jellyfin.Plugin.TubeArchivistMetadata.dll"
changelog: >
Add support for Jellyfin 10.9.1
Introduce bidirectional playlists synchronization
+72 -8
View File
@@ -8,6 +8,70 @@
"overview": "Metadata for your TubeArchivist library on Jellyfin",
"imageUrl": "https://raw.githubusercontent.com/tubearchivist/tubearchivist-jf-plugin/master/images/logo.png",
"versions": [
{
"version": "1.4.0.0",
"changelog": "Align compatibility with Jellyfin 10.11.x\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": "7afb9abd4361ee991c1b3eae2a8d4cec",
"timestamp": "2025-10-21T21:34:51Z"
},
{
"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 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": "6162235807535a5c2a7570276798e69e",
"timestamp": "2024-09-14T13:41:58Z"
},
{
"version": "1.3.1.0",
"changelog": "Add support for Jellyfin 10.9.1\n",
"targetAbi": "10.9.1.0",
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.1/tubearchivistmetadata_1.3.1.0.zip",
"checksum": "b0b526bbd1702970108e3b9a8c3709de",
"timestamp": "2024-09-12T13:36:12Z"
},
{
"version": "1.3.0.0",
"changelog": "Add support for Jellyfin 10.9.1\n",
@@ -30,7 +94,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",
@@ -38,7 +102,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",
@@ -46,7 +110,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",
@@ -54,7 +118,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",
@@ -62,7 +126,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",
@@ -70,7 +134,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",
@@ -78,7 +142,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",
@@ -86,7 +150,7 @@
"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"
}
]
}