Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d49fd4e1a1 | |||
| 13553ef1c6 | |||
| ac2e4dac9c | |||
| 0bbb3c36cd | |||
| db0d9b115c | |||
| bc343dd0c8 | |||
| 9ed4869ece | |||
| c2eebbe1a9 | |||
| e198b0fb92 | |||
| f6f6bbf978 | |||
| 75c2fce017 | |||
| 3c08e3c70c | |||
| d5c706b833 | |||
| ad4fb92577 | |||
| 8aec7f804d | |||
| 4c3de5146b | |||
| 1ce6587078 | |||
| 7942e89341 | |||
| 764dc8f326 | |||
| af8a02d289 | |||
| 9d745b0bc5 | |||
| a8e0896284 | |||
| 53d9a17c2a | |||
| 0c8bc243c6 | |||
| 03ea6b9e5c | |||
| 6c9a824546 | |||
| 5dc99e2fcf | |||
| 478537bf0a | |||
| 184c53ba00 | |||
| 70f063d33b | |||
| 60753cb828 | |||
| cba12adaa4 | |||
| d94fd1bac3 | |||
| dfdfac6be7 | |||
| 806efa9dac | |||
| 4ddb5cedac | |||
| cfa78f2a89 | |||
| 54cb513ae8 | |||
| bee5538d19 | |||
| 40194f9f26 | |||
| 67c6fd0422 | |||
| 7aa6af30fa | |||
| 7e496643f8 | |||
| 6e7dbd859c | |||
| 13c4144aab | |||
| 98f73185d1 | |||
| f7cbff910d | |||
| 2d5327d31b | |||
| a2e29dee3b | |||
| cf87c6e829 | |||
| d7f4f5cbcc | |||
| 6e8b9ca744 | |||
| b0ca3a49a5 | |||
| 4358072001 | |||
| bdd99441d1 | |||
| 38ed5dd9e0 | |||
| cfd94260f1 | |||
| a28b4ccd7c | |||
| ff55b9f517 | |||
| 93a2de5d46 | |||
| 5b62e4c001 | |||
| 50c0e1cef2 | |||
| ca0a9215fe | |||
| 6679867f74 | |||
| 6926a44e7c | |||
| d386f79dfd | |||
| 46dd4fcf5b | |||
| ab783238c3 | |||
| ff99517d12 | |||
| 532438d110 | |||
| 8e4194f56b | |||
| bff699fc3f | |||
| f3a9050311 | |||
| 3cb340c61a | |||
| 4317fdbf63 | |||
| a9c6611dcf | |||
| e28a3ea37b | |||
| 86dd1769ec | |||
| 4711a6a742 | |||
| a783b3c739 | |||
| 3e95ca26aa | |||
| a58139eca0 | |||
| 5ab5224328 | |||
| d36d677179 | |||
| 784592b362 | |||
| 2f90d6a03e | |||
| af375987ee | |||
| ee683d5737 | |||
| 583e94b35a | |||
| 80a1b6050d | |||
| a4942e78b6 | |||
| 48070f311e | |||
| 96a72ab909 | |||
| 9c6aa56db0 | |||
| 413f45046d | |||
| 5ad6b2a381 | |||
| 795047a50e | |||
| 323a208de2 | |||
| 55d320c456 | |||
| d5f2f0ae8e | |||
| c3bdfc26ea | |||
| c6f7f79385 | |||
| 53ede13f54 | |||
| eeda7dd91e | |||
| 867fbbae5b | |||
| 76ac750de2 | |||
| ddbbb4ba25 | |||
| 3fca2d1e09 | |||
| 4f2f6c0429 | |||
| ebb2cdfcc6 | |||
| c058a35abe | |||
| 11eb456c56 | |||
| 4db1ce755a | |||
| a424721d93 | |||
| 16598bb86e | |||
| 98472b5aa6 | |||
| ab96b227a1 | |||
| 5ff96113c8 | |||
| 6697f2b4cc | |||
| a45ef87f84 | |||
| 157facdceb | |||
| be8481c795 | |||
| bd0a8371a5 | |||
| a75c474647 | |||
| 1dd7019676 | |||
| 355576c283 | |||
| 9091cdbe5b | |||
| a162ebb25d | |||
| 36eec27d9f | |||
| 2fee654efe | |||
| ce8aea629b | |||
| a8f821625b | |||
| 6cb2d89b95 | |||
| 080f3856b7 | |||
| b436ae13a1 | |||
| dce122840c | |||
| 9129bbda71 | |||
| 698237a77a | |||
| 0bb2cb1108 | |||
| 286d43c840 | |||
| 622079c887 | |||
| c9cc4f150e | |||
| 52342b3dc3 | |||
| 9fff31c451 | |||
| 233595baac | |||
| a968ae838c | |||
| 44d00af16f | |||
| 8edb8c9202 | |||
| 625b6b6958 | |||
| 6e76761994 | |||
| b07ffb2a83 | |||
| b3ef252edb | |||
| 4ffdbaf7f8 | |||
| fc221e4407 | |||
| b65f6332cb | |||
| 1a3fa6e823 | |||
| 962794c62c | |||
| 9a40ba93e8 | |||
| fd4a9a0130 | |||
| cc28016984 | |||
| 9179ea8602 | |||
| 6675f7f762 | |||
| 8b49afad25 | |||
| e355c304a2 | |||
| 2bdee20beb | |||
| a085a5a4bf | |||
| 3f0267ced0 | |||
| 70123f597a | |||
| b4d6ea2646 | |||
| e0ef8b1e86 | |||
| 362582dddf | |||
| 1c49427761 | |||
| 49effb3d3f | |||
| 66e6a49cc7 | |||
| 3dca19c3e5 | |||
| 55ab16a01a | |||
| 08c8df0292 | |||
| 40d5c90c4c | |||
| 634b3f7dc7 | |||
| 195acd2f1b | |||
| 14032d3ed3 |
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ jobs:
|
||||
name: Build & Release
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -26,7 +27,15 @@ jobs:
|
||||
- name: Setup .Net
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Add Jellyfin GitHub Nuget registry to sources
|
||||
run: |
|
||||
dotnet nuget add source \
|
||||
--username DarkFighterLuke \
|
||||
--password ${{ secrets.GITHUB_TOKEN }} \
|
||||
--store-password-in-clear-text \
|
||||
--name github https://nuget.pkg.github.com/jellyfin/index.json
|
||||
|
||||
- name: Get version number only
|
||||
run: |
|
||||
@@ -51,10 +60,10 @@ jobs:
|
||||
|
||||
|
||||
- name: Build Jellyfin Plugin
|
||||
uses: DarkFighterLuke/jellyfin-plugin-repository-manager@f99da255f4e907df607db19f181258d137aa7a17
|
||||
uses: DarkFighterLuke/jellyfin-plugin-repository-manager@7a96768accc155ac7b597351b5033532f3c48173
|
||||
id: jprm
|
||||
with:
|
||||
dotnet-target: net6.0
|
||||
dotnet-target: net9.0
|
||||
version: ${VERSION}
|
||||
update-manifest: "true"
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ obj/
|
||||
.vs/
|
||||
.idea/
|
||||
artifacts
|
||||
nuget.config
|
||||
|
||||
Vendored
+6
-5
@@ -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,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.2.3.0</Version>
|
||||
<AssemblyVersion>1.2.3.0</AssemblyVersion>
|
||||
<FileVersion>1.2.3.0</FileVersion>
|
||||
<Version>1.4.4.0</Version>
|
||||
<AssemblyVersion>1.4.4.0</AssemblyVersion>
|
||||
<FileVersion>1.4.4.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// The NumberingScheme.
|
||||
/// </summary>
|
||||
public enum NumberingScheme
|
||||
{
|
||||
/// <summary>
|
||||
/// Default (no numbering).
|
||||
/// </summary>
|
||||
Default,
|
||||
|
||||
/// <summary>
|
||||
/// YYYYMMDD (e.g. 20250804 for August 4th, 2025).
|
||||
/// </summary>
|
||||
YYYYMMDD,
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +16,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
|
||||
private ILogger _logger;
|
||||
private string _tubeArchivistUrl;
|
||||
private string _tubeArchivistApiKey;
|
||||
private HashSet<string> _jfUsernamesTo;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
@@ -33,6 +36,17 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
|
||||
_tubeArchivistUrl = string.Empty;
|
||||
_tubeArchivistApiKey = string.Empty;
|
||||
MaxDescriptionLength = 500;
|
||||
JFTAProgressSync = false;
|
||||
JFUsernameFrom = string.Empty;
|
||||
TAJFProgressSync = false;
|
||||
JFTAPlaylistsSync = false;
|
||||
JFTAPlaylistsDelete = false;
|
||||
TAJFPlaylistsSync = false;
|
||||
TAJFPlaylistsDelete = false;
|
||||
_jfUsernamesTo = new HashSet<string>();
|
||||
TAJFProgressTaskInterval = 60;
|
||||
JFTAPlaylistsSyncTaskInterval = 60;
|
||||
TAJFPlaylistsSyncTaskInterval = 60;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,6 +94,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
|
||||
{
|
||||
_tubeArchivistApiKey = value;
|
||||
Plugin.Instance?.LogTAApiConnectionStatus();
|
||||
Plugin.Instance?.UpdateAuthorizationHeader(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,5 +102,102 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Configuration
|
||||
/// Gets or sets maximum series and episodes overviews length.
|
||||
/// </summary>
|
||||
public int MaxDescriptionLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable TA->JF playback progress synchronization.
|
||||
/// </summary>
|
||||
public bool TAJFProgressSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable JF->TA playback progress synchronization.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
public string JFUsernameFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist.
|
||||
/// </summary>
|
||||
public string JFUsernamesTo
|
||||
{
|
||||
get
|
||||
{
|
||||
_logger.LogInformation("JFUsernamesTo configured: {Message}", string.Join(", ", _jfUsernamesTo));
|
||||
return string.Join(", ", _jfUsernamesTo);
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 TAJFProgressTaskInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval in seconds at which the Jellyfin to TubeArchivist playlists synchronization task should run.
|
||||
/// It requires Jellyfin server restart to take effect.
|
||||
/// </summary>
|
||||
public int JFTAPlaylistsSyncTaskInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval in seconds at which the TubeArchivist to Jellyfin playlists synchronization task should run.
|
||||
/// It requires Jellyfin server restart to take effect.
|
||||
/// </summary>
|
||||
public int TAJFPlaylistsSyncTaskInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the preferred numbering scheme for episodes (index number) in Jellyfin.
|
||||
/// </summary>
|
||||
public NumberingScheme EpisodeNumberingScheme { get; set; } = NumberingScheme.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playback progress owners Jellyfin usernames to synchronize data from TubeArchivist.
|
||||
/// </summary>
|
||||
/// <returns>An array of usernames.</returns>
|
||||
public HashSet<string> GetJFUsernamesToArray()
|
||||
{
|
||||
return _jfUsernamesTo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Template</title>
|
||||
<title>TubeArchivistMetadata</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -12,25 +12,138 @@
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="TemplateConfigForm">
|
||||
<div>
|
||||
<h1>TubeArchivistMetadata</h1>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="CollectionTitle">Collection Title</label>
|
||||
<input id="CollectionTitle" name="CollectionTitle" type="text" is="emby-input" placeholder="eg. YouTube" />
|
||||
<input id="CollectionTitle" name="CollectionTitle" type="text" is="emby-input"
|
||||
placeholder="eg. YouTube" />
|
||||
<div class="fieldDescription">TubeArchivist collection display name</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="TubeArchivistUrl">TubeArchivist URL</label>
|
||||
<input id="TubeArchivistUrl" name="TubeArchivistUrl" type="text" is="emby-input" placeholder="http://..." />
|
||||
<input id="TubeArchivistUrl" name="TubeArchivistUrl" type="text" is="emby-input"
|
||||
placeholder="http://..." />
|
||||
<div class="fieldDescription">TubeArchivist URL</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="TubeArchivistApiKey">TubeArchivist API key</label>
|
||||
<label class="inputLabel inputLabelUnfocused" for="TubeArchivistApiKey">TubeArchivist API
|
||||
key</label>
|
||||
<input id="TubeArchivistApiKey" name="TubeArchivistApiKey" type="text" is="emby-input" />
|
||||
<div class="fieldDescription">TubeArchivist API key</div>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaxDescriptionLength">Maximum overviews length</label>
|
||||
<input id="MaxDescriptionLength" name="MaxDescriptionLength" type="number" is="emby-input" min="0" />
|
||||
<div class="fieldDescription">This is the maximum length of the descriptions showed in series and episodes</div>
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaxDescriptionLength">Maximum overviews
|
||||
length</label>
|
||||
<input id="MaxDescriptionLength" name="MaxDescriptionLength" type="number" is="emby-input"
|
||||
min="0" />
|
||||
<div class="fieldDescription">This is the maximum length of the descriptions showed in series
|
||||
and episodes</div>
|
||||
</div>
|
||||
<div class="selectContainer">
|
||||
<label class="selectLabel" for="EpisodeNumberingScheme">Episode numbering scheme</label>
|
||||
<select is="emby-select" id="EpisodeNumberingScheme" name="EpisodeNumberingScheme" label="Description markup" class="emby-select-withcolor emby-select">
|
||||
<option value="Default">Default (no specified numbering)</option>
|
||||
<option value="YYYYMMDD">YYYYMMDD (e.g. 20250804 for August 4th, 2025)</option>
|
||||
</select>
|
||||
<div class="selectArrowContainer">
|
||||
<span class="selectArrow material-icons keyboard_arrow_down" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="fieldDescription">This is the numbering scheme for episodes in Jellyfin (IndexNumber).
|
||||
Default is the same as no numbering which allows Jellyfin to use the fallback numbering scheme.
|
||||
</div>
|
||||
<div>
|
||||
<h2>Synchronization</h2>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Jellyfin to TubeArchivist</h3>
|
||||
</div>
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="JFUsernameFrom">Jellyfin username to sync
|
||||
from</label>
|
||||
<input id="JFUsernameFrom" name="JFUsernameFrom" type="text" is="emby-input"
|
||||
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="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>
|
||||
<input id="JFUsernamesTo" name="JFUsernamesTo" type="text" is="emby-input"
|
||||
placeholder="Username1, username2, ..." />
|
||||
<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="TAJFProgressTaskInterval">TubeArchivist to Jellyfin
|
||||
playback progress synchronization interval</label>
|
||||
<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">
|
||||
@@ -53,6 +166,16 @@
|
||||
document.querySelector('#TubeArchivistUrl').value = config.TubeArchivistUrl;
|
||||
document.querySelector('#TubeArchivistApiKey').value = config.TubeArchivistApiKey;
|
||||
document.querySelector('#MaxDescriptionLength').value = config.MaxDescriptionLength;
|
||||
document.querySelector('#JFTAProgressSync').checked = config.JFTAProgressSync;
|
||||
document.querySelector('#JFUsernamesTo').value = config.JFUsernamesTo;
|
||||
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('#EpisodeNumberingScheme').value = config.EpisodeNumberingScheme;
|
||||
document.querySelector('#TAJFProgressTaskInterval').value = config.TAJFProgressTaskInterval;
|
||||
document.querySelector('#JFTAPlaylistsSyncTaskInterval').value = config.JFTAPlaylistsSyncTaskInterval;
|
||||
document.querySelector('#TAJFPlaylistsSyncTaskInterval').value = config.TAJFPlaylistsSyncTaskInterval;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +188,16 @@
|
||||
config.TubeArchivistUrl = document.querySelector('#TubeArchivistUrl').value;
|
||||
config.TubeArchivistApiKey = document.querySelector('#TubeArchivistApiKey').value;
|
||||
config.MaxDescriptionLength = document.querySelector('#MaxDescriptionLength').value;
|
||||
config.JFTAProgressSync = document.querySelector('#JFTAProgressSync').checked;
|
||||
config.JFUsernamesTo = document.querySelector('#JFUsernamesTo').value;
|
||||
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.EpisodeNumberingScheme = document.querySelector('#EpisodeNumberingScheme').value;
|
||||
config.TAJFProgressTaskInterval = document.querySelector('#TAJFProgressTaskInterval').value;
|
||||
config.JFTAPlaylistsSyncTaskInterval = document.querySelector('#JFTAPlaylistsSyncTaskInterval').value;
|
||||
config.TAJFPlaylistsSyncTaskInterval = document.querySelector('#TAJFPlaylistsSyncTaskInterval').value;
|
||||
ApiClient.updatePluginConfiguration(TAMetadataConfig.pluginUniqueId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.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.8.13" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.8.13" />
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Tasks;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
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;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata
|
||||
@@ -18,13 +28,29 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger)
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> 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,
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataManager,
|
||||
IPlaylistManager playlistManager)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
@@ -33,7 +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;
|
||||
|
||||
logger.LogInformation("{Message}", "Collection display name: " + Instance?.Configuration.CollectionTitle);
|
||||
logger.LogInformation("{Message}", "TubeArchivist API URL: " + Instance?.Configuration.TubeArchivistUrl);
|
||||
logger.LogInformation("{Message}", "Pinging TubeArchivist API...");
|
||||
@@ -60,6 +94,16 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
|
||||
/// </summary>
|
||||
public HttpClient HttpClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SessionManager used globally by the plugin.
|
||||
/// </summary>
|
||||
public ISessionManager SessionManager { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the LibraryManager used globally by the plugin.
|
||||
/// </summary>
|
||||
public ILibraryManager LibraryManager { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages() => new[]
|
||||
{
|
||||
@@ -70,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>
|
||||
@@ -92,5 +153,119 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata
|
||||
},
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private async void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs eventArgs)
|
||||
{
|
||||
if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
|
||||
{
|
||||
Logger.LogDebug("Skipping progress synchronization: PlaybackProgress event triggered with null or empty Guid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var topParent = eventArgs.Item.GetTopParent();
|
||||
if (
|
||||
Instance!.Configuration.JFTAProgressSync &&
|
||||
eventArgs.Users.Any(u => Instance!.Configuration.JFUsernameFrom.Equals(u.Username, StringComparison.Ordinal)) &&
|
||||
eventArgs.PlaybackPositionTicks.HasValue &&
|
||||
string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
long progress = (long)eventArgs.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
|
||||
var videoId = Utils.GetVideoNameFromPath(eventArgs.Item.Path);
|
||||
try
|
||||
{
|
||||
var statusCode = await TubeArchivistApi.GetInstance().SetProgress(videoId, progress).ConfigureAwait(true);
|
||||
if (statusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Logger.LogCritical("{Message}", $"POST /video/{videoId}/progress returned {statusCode} for video {eventArgs.Item.Name} with progress {progress} seconds");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogCritical("An exception occurred while calling POST /video/{VideoId}/progress for for video {VideoName} with progress {Progress} seconds: {ExceptionMessage}", videoId, eventArgs.Item.Name, progress, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnWatchedStatusChange(object? sender, UserDataSaveEventArgs eventArgs)
|
||||
{
|
||||
if (eventArgs == null || eventArgs.Item.Id == Guid.Empty)
|
||||
{
|
||||
Logger.LogDebug("Skipping watched status synchronization: WatchedStatusChange event triggered with null or empty Guid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var topParent = eventArgs.Item.GetTopParent();
|
||||
var user = _userManager.GetUserById(eventArgs.UserId);
|
||||
if (
|
||||
Configuration.JFTAProgressSync
|
||||
&& user == null
|
||||
&& string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
Logger.LogError("OnWatchedStatusChange callback called without user id for item {ItemName}", eventArgs.Item.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Configuration.JFTAProgressSync
|
||||
&& user != null
|
||||
&& string.Equals(Configuration.JFUsernameFrom, user!.Username, StringComparison.Ordinal)
|
||||
&& string.Equals(topParent?.Name, Instance?.Configuration.CollectionTitle, StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
var userItemData = _userDataManager.GetUserData(user, eventArgs.Item);
|
||||
var isPlayed = eventArgs.Item.IsPlayed(user, userItemData);
|
||||
Logger.LogDebug("User {UserId} changed watched status to {Status} for the item {ItemName}", eventArgs.UserId, isPlayed, eventArgs.Item.Name);
|
||||
string itemYTId;
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error while processing item path: {ItemPath}", eventArgs.Item.Path ?? "null");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -63,10 +63,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
|
||||
{
|
||||
Name = video.Channel.Name,
|
||||
ImageUrl = video.Channel.ThumbUrl,
|
||||
Type = PersonType.Actor,
|
||||
Type = Data.Enums.PersonKind.Actor,
|
||||
});
|
||||
result.HasMetadata = true;
|
||||
result.Item = video.ToEpisode();
|
||||
result.Item.Path = info.Path;
|
||||
result.Provider = Name;
|
||||
result.People = peopleInfo;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -62,7 +62,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Providers
|
||||
{
|
||||
Name = channel.Name,
|
||||
ImageUrl = channel.ThumbUrl,
|
||||
Type = PersonType.Actor,
|
||||
Type = Data.Enums.PersonKind.Actor,
|
||||
});
|
||||
result.HasMetadata = true;
|
||||
result.Item = channel.ToSeries();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
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.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Task to sync Jellyfin playback progresses to TubeArchivist.
|
||||
/// </summary>
|
||||
public class JFToTubeArchivistProgressSyncTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "JFToTubeArchivistProgressSyncTask";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => "This tasks syncs Jellyfin playback progresses to TubeArchivist";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Category => "TubeArchivistMetadata";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Key => "JFToTubeArchivistProgressSyncTask";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
progress.Report(0);
|
||||
if (Plugin.Instance!.Configuration.JFTAProgressSync)
|
||||
{
|
||||
var start = DateTime.Now;
|
||||
_logger.LogInformation("Starting Jellyfin->TubeArchivist playback progresses synchronization.");
|
||||
var taApi = TubeArchivistApi.GetInstance();
|
||||
var videosCount = 0;
|
||||
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 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)
|
||||
{
|
||||
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 channelYTId = Utils.GetChannelNameFromPath(channel.Path);
|
||||
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;
|
||||
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
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Episode }
|
||||
});
|
||||
videosCount += videos.Count;
|
||||
|
||||
foreach (Episode video in videos)
|
||||
{
|
||||
var videoYTId = Utils.GetVideoNameFromPath(video.Path);
|
||||
_logger.LogDebug("Current video extracted YouTube id: {VideoYtId}", videoYTId);
|
||||
HttpStatusCode statusCode;
|
||||
var channelItemData = _userDataManager.GetUserData(user, channel);
|
||||
|
||||
if (!isChannelCheckedForWatched && channel.IsPlayed(user, channelItemData))
|
||||
{
|
||||
var isChannelPlayed = channel.IsPlayed(user, channelItemData);
|
||||
statusCode = await taApi.SetWatchedStatus(channelYTId, isChannelPlayed).ConfigureAwait(true);
|
||||
if (statusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for channel {channel.Name} ({channelYTId}) with wacthed status {isChannelPlayed}");
|
||||
}
|
||||
else
|
||||
{
|
||||
isChannelWatched = true;
|
||||
}
|
||||
|
||||
isChannelCheckedForWatched = true;
|
||||
}
|
||||
|
||||
var videoItemData = _userDataManager.GetUserData(user, video);
|
||||
if (!isChannelWatched)
|
||||
{
|
||||
var isVideoPlayed = video.IsPlayed(user, videoItemData);
|
||||
var taVideo = await taApi.GetVideo(videoYTId).ConfigureAwait(true);
|
||||
if (taVideo != null)
|
||||
{
|
||||
var isTAVideoPlayed = taVideo?.Player.IsWatched ?? false;
|
||||
if (isTAVideoPlayed != isVideoPlayed)
|
||||
{
|
||||
statusCode = await taApi.SetWatchedStatus(videoYTId, isVideoPlayed).ConfigureAwait(true);
|
||||
if (statusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogCritical("{Message}", $"POST /watched returned {statusCode} for video {video.Name} ({videoYTId}) with wacthed status {isVideoPlayed}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Video {VideoId} watch status marked as {Status} in TubeArchivist", videoYTId, isVideoPlayed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("{Message}", isVideoPlayed);
|
||||
if (!isVideoPlayed)
|
||||
{
|
||||
var playbackProgress = _userDataManager.GetUserData(user, video)?.PlaybackPositionTicks / TimeSpan.TicksPerSecond;
|
||||
if (playbackProgress != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
statusCode = await taApi.SetProgress(videoYTId, playbackProgress.Value).ConfigureAwait(true);
|
||||
if (statusCode != System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
_logger.LogCritical("{Message}", $"POST /video/{videoYTId}/progress returned {statusCode} for video {video.Name} with progress {progress} seconds");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical("An exception occurred while calling POST /video/{VideoId}/progress for for video {VideoName} with progress {Progress} seconds: {ExceptionMessage}", videoYTId, videoYTId, playbackProgress.Value, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processedVideosCount++;
|
||||
progress.Report(processedVideosCount * 100 / videosCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Jellyfin->TubeArchivist playback synchronization is currently disabled.");
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.StartupTrigger,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Playlists;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Task to sync TubeArchivist playlists to Jellyfin.
|
||||
/// </summary>
|
||||
public class TAToJellyfinPlaylistsSyncTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IPlaylistManager _playlistManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TAToJellyfinPlaylistsSyncTask"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="userManager">User manager.</param>
|
||||
/// <param name="playlistManager">Playlists manager.</param>
|
||||
public TAToJellyfinPlaylistsSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IPlaylistManager playlistManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_playlistManager = playlistManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "TAToJellyfinPlaylistsSyncTask";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => "This tasks syncs TubeArchivist playlists to Jellyfin";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Category => "TubeArchivistMetadata";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Key => "TAToJellyfinPlaylistsSyncTask";
|
||||
|
||||
private int CountTotalVideos(ISet<TubeArchivist.Playlist> taPlaylists)
|
||||
{
|
||||
var totalEntries = 0;
|
||||
foreach (var taPlaylist in taPlaylists)
|
||||
{
|
||||
totalEntries += taPlaylist.Entries.Count;
|
||||
}
|
||||
|
||||
return totalEntries;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
progress.Report(0);
|
||||
if (Plugin.Instance!.Configuration.TAJFPlaylistsSync)
|
||||
{
|
||||
var start = DateTime.Now;
|
||||
_logger.LogInformation("Starting TubeArchivist->Jellyfin playlists synchronization.");
|
||||
var taApi = TubeArchivistApi.GetInstance();
|
||||
_logger.LogInformation("Getting TubeArchivist playlists");
|
||||
var taPlaylists = await taApi.GetPlaylists().ConfigureAwait(true);
|
||||
_logger.LogInformation("Received playlists:\n{Playlists}", taPlaylists);
|
||||
if (taPlaylists != null)
|
||||
{
|
||||
var totalVideosCount = CountTotalVideos(taPlaylists);
|
||||
var processedVideosCount = 0;
|
||||
foreach (var jfUsername in Plugin.Instance!.Configuration.GetJFUsernamesToArray())
|
||||
{
|
||||
var user = _userManager.GetUserByName(jfUsername);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogInformation("{Message}", $"Jellyfin user with username {jfUsername} not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
var userPlaylists = _playlistManager.GetPlaylists(user.Id).ToList();
|
||||
|
||||
if (Plugin.Instance!.Configuration.TAJFPlaylistsDelete)
|
||||
{
|
||||
var jfPlaylistsToDelete = userPlaylists.Where(up => !taPlaylists.Select(tp => tp.Id).Contains(Utils.GetTAPlaylistIdFromName(up.Name)));
|
||||
foreach (var jfPlaylistToDelete in jfPlaylistsToDelete)
|
||||
{
|
||||
_logger.LogInformation("Deleting Jellyfin playlist {PlaylistName}", jfPlaylistToDelete.Name);
|
||||
_libraryManager.DeleteItem(jfPlaylistToDelete, new DeleteOptions());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var taPlaylist in taPlaylists)
|
||||
{
|
||||
var playlistName = taPlaylist.Type == PlaylistType.Regular ? $"{taPlaylist.Name} - {taPlaylist.Channel} ({taPlaylist.Id})" : $"{taPlaylist.Name} ({taPlaylist.Id})";
|
||||
var userPlaylist = userPlaylists.Where(up => up.Name == playlistName).FirstOrDefault();
|
||||
|
||||
var jfEntryIds = new List<Guid>();
|
||||
var currentPlaylistVideos = 0;
|
||||
foreach (var taEntry in taPlaylist.Entries)
|
||||
{
|
||||
currentPlaylistVideos++;
|
||||
var taEntryStr = $"{taEntry.Uploader} - {taEntry.Title} ({taEntry.YoutubeId}) in playlist {playlistName}";
|
||||
if (!taEntry.IsDownloaded)
|
||||
{
|
||||
_logger.LogInformation("The entry {TAEntry} was skipped because has not been downloaded by TubeArchivist", taEntryStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
var entries = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
HasAnyProviderId = new Dictionary<string, string>()
|
||||
{
|
||||
{
|
||||
Constants.ProviderName, taEntry.YoutubeId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var jfEntry = entries.Count > 0 ? entries[0] : null;
|
||||
|
||||
if (jfEntry != null)
|
||||
{
|
||||
jfEntryIds.Add(jfEntry.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("The relative video for {TAEntry} was not found on Jellyfin", taEntryStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (userPlaylist != null)
|
||||
{
|
||||
var updateRequest = new PlaylistUpdateRequest
|
||||
{
|
||||
Id = userPlaylist.Id,
|
||||
UserId = user.Id,
|
||||
Name = playlistName,
|
||||
Ids = jfEntryIds
|
||||
};
|
||||
|
||||
var result = _playlistManager.UpdatePlaylist(updateRequest);
|
||||
_logger.LogInformation("Updated playlist {PlaylistName} with id {Id}", playlistName, result.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
var creationRequest = new PlaylistCreationRequest
|
||||
{
|
||||
Name = playlistName,
|
||||
ItemIdList = jfEntryIds,
|
||||
MediaType = MediaType.Video,
|
||||
UserId = user.Id
|
||||
};
|
||||
|
||||
var result = await _playlistManager.CreatePlaylist(creationRequest).ConfigureAwait(true);
|
||||
_logger.LogInformation("Created playlist {PlaylistName} with id {Id}", playlistName, result.Id);
|
||||
}
|
||||
|
||||
processedVideosCount += currentPlaylistVideos;
|
||||
|
||||
if (totalVideosCount == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
}
|
||||
else
|
||||
{
|
||||
progress.Report(processedVideosCount * 100 / totalVideosCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("TubeArchivist->Jellyfin playlists synchronization is currently disabled.");
|
||||
}
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||
{
|
||||
return
|
||||
[
|
||||
new TaskTriggerInfo
|
||||
{
|
||||
Type = TaskTriggerInfoType.IntervalTrigger,
|
||||
IntervalTicks = TimeSpan.FromSeconds(Plugin.Instance!.Configuration.TAJFProgressTaskInterval).Ticks
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.Tasks
|
||||
{
|
||||
/// <summary>
|
||||
/// Task to sync TubeArchivist playback progresses to Jellyfin.
|
||||
/// </summary>
|
||||
public class TAToJellyfinProgressSyncTask : IScheduledTask
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TAToJellyfinProgressSyncTask"/> 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 TAToJellyfinProgressSyncTask(ILogger<Plugin> logger, ILibraryManager libraryManager, IUserManager userManager, IUserDataManager userDataManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "TAToJellyfinProgressSyncTask";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => "This tasks syncs TubeArchivist playback progresses to Jellyfin";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Category => "TubeArchivistMetadata";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Key => "TAToJellyfinProgressSyncTask";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogInformation("{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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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 years = channel.GetChildren(user, false, new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Season }
|
||||
});
|
||||
|
||||
foreach (Season year in years)
|
||||
{
|
||||
var videos = year.GetChildren(user, false, new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Episode }
|
||||
});
|
||||
|
||||
foreach (Episode video in videos)
|
||||
{
|
||||
var playbackProgress = await taApi.GetProgress(Utils.GetVideoNameFromPath(video.Path)).ConfigureAwait(true);
|
||||
if (playbackProgress != null)
|
||||
{
|
||||
var userItemData = _userDataManager.GetUserData(user, video);
|
||||
var userUpdateData = new UpdateUserItemDataDto
|
||||
{
|
||||
PlaybackPositionTicks = playbackProgress.Position * TimeSpan.TicksPerSecond
|
||||
};
|
||||
|
||||
var taVideoInfo = await taApi.GetVideo(Utils.GetVideoNameFromPath(video.Path)).ConfigureAwait(true);
|
||||
if (taVideoInfo != null)
|
||||
{
|
||||
if (taVideoInfo.Player.IsWatched)
|
||||
{
|
||||
userUpdateData.Played = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
userUpdateData.Played = false;
|
||||
}
|
||||
}
|
||||
|
||||
_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}.");
|
||||
|
||||
processedVideosCount++;
|
||||
progress.Report(processedVideosCount * 100 / videosCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Time elapsed: {Time}", DateTime.Now - start);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("TubeArchivist->Jellyfin playback 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
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API video watched status.
|
||||
/// </summary>
|
||||
public class Watched
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Watched"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">Id of the video/channel/playlist.</param>
|
||||
/// <param name="isWatched">Watched status.</param>
|
||||
public Watched(string id, bool isWatched)
|
||||
{
|
||||
Id = id;
|
||||
IsWatched = isWatched;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the id of the video/channel/playlist.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
public string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the item has been watched or not.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "is_watched")]
|
||||
public bool IsWatched { get; }
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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,99 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// A class representing pagination information from the TubeArchivist API.
|
||||
/// </summary>
|
||||
public class PaginationInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PaginationInfo"/> class.
|
||||
/// </summary>
|
||||
/// <param name="pageSize">The size of the page.</param>
|
||||
/// <param name="pageFrom">The starting page number.</param>
|
||||
/// <param name="prevPages">The collection of previous page numbers.</param>
|
||||
/// <param name="currentPage">The current page number.</param>
|
||||
/// <param name="maxHits">A value indicating whether the max hits have been reached.</param>
|
||||
/// <param name="parameters">The parameters used for pagination.</param>
|
||||
/// <param name="lastPage">The last page number.</param>
|
||||
/// <param name="nextPages">The collection of next page numbers.</param>
|
||||
/// <param name="totalHits">The total number of hits.</param>
|
||||
public PaginationInfo(
|
||||
int pageSize,
|
||||
int pageFrom,
|
||||
Collection<int> prevPages,
|
||||
int currentPage,
|
||||
bool maxHits,
|
||||
string parameters,
|
||||
int lastPage,
|
||||
Collection<int> nextPages,
|
||||
int totalHits)
|
||||
{
|
||||
this.PageSize = pageSize;
|
||||
this.PageFrom = pageFrom;
|
||||
this.PrevPages = prevPages;
|
||||
this.CurrentPage = currentPage;
|
||||
this.MaxHits = maxHits;
|
||||
this.Parameters = parameters;
|
||||
this.LastPage = lastPage;
|
||||
this.NextPages = nextPages;
|
||||
this.TotalHits = totalHits;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page size.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "page_size")]
|
||||
public int PageSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page from.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "page_from")]
|
||||
public int PageFrom { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the previous pages.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "prev_pages")]
|
||||
public Collection<int> PrevPages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current page.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "current_page")]
|
||||
public int CurrentPage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the max hits have been reached.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "max_hits")]
|
||||
public bool MaxHits { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameters.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "params")]
|
||||
public string Parameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last page.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "last_page")]
|
||||
public int LastPage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next pages.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "next_pages")]
|
||||
public Collection<int> NextPages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total hits.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "total_hits")]
|
||||
public int TotalHits { get; }
|
||||
}
|
||||
}
|
||||
+25
@@ -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; }
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum representing the possible actions to execute on a TubeArchivist custom playlist video.
|
||||
/// </summary>
|
||||
public enum CustomPlaylistAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new custom playlist video.
|
||||
/// </summary>
|
||||
Create,
|
||||
|
||||
/// <summary>
|
||||
/// Removea custom playlist video.
|
||||
/// </summary>
|
||||
Remove,
|
||||
|
||||
/// <summary>
|
||||
/// Move a custom playlist video to the top of the list.
|
||||
/// </summary>
|
||||
Top,
|
||||
|
||||
/// <summary>
|
||||
/// Move a custom playlist video to the bottom of the list.
|
||||
/// </summary>
|
||||
Bottom,
|
||||
|
||||
/// <summary>
|
||||
/// Move a custom playlist video up in the list.
|
||||
/// </summary>
|
||||
Up,
|
||||
|
||||
/// <summary>
|
||||
/// Move a custom playlist video down in the list.
|
||||
/// </summary>
|
||||
Down
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API custom playlist entry operation request.
|
||||
/// </summary>
|
||||
public class CustomPlaylistEntryAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CustomPlaylistEntryAction"/> class.
|
||||
/// </summary>
|
||||
/// <param name="action">The standard action to create a new entry.</param>
|
||||
/// <param name="videoId">The entry YoutubeId.</param>
|
||||
public CustomPlaylistEntryAction(
|
||||
CustomPlaylistAction action,
|
||||
string videoId)
|
||||
{
|
||||
this.Action = action.ToString().ToLower(CultureInfo.CurrentCulture);
|
||||
this.VideoId = videoId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the standard action to create a new entry.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "action")]
|
||||
public string Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry YoutubeId.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "video_id")]
|
||||
public string VideoId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// Enum representing whether the playlist on TubeArchivist belongs from YouTube or is created by the user.
|
||||
/// </summary>
|
||||
public enum PlaylistType
|
||||
{
|
||||
/// <summary>
|
||||
/// Playlist belongs from YouTube.
|
||||
/// </summary>
|
||||
Regular,
|
||||
|
||||
/// <summary>
|
||||
/// Playlist created by the user.
|
||||
/// </summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API playlist data.
|
||||
/// </summary>
|
||||
public class Playlist
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Playlist"/> class.
|
||||
/// </summary>
|
||||
/// <param name="isActive">URL of the channel banner image.</param>
|
||||
/// <param name="channel">Playlist channel name.</param>
|
||||
/// <param name="channelId">Playlist channel YouTube id.</param>
|
||||
/// <param name="description">Playlist description.</param>
|
||||
/// <param name="entries">Playlist videos.</param>
|
||||
/// <param name="id">Playlist YouTube id.</param>
|
||||
/// <param name="name">Playlist name.</param>
|
||||
/// <param name="thumbnailUrl">URL of the playlist thumbnail.</param>
|
||||
/// <param name="type">Type of the playlist. See <see cref="PlaylistType"/>.</param>
|
||||
public Playlist(
|
||||
bool isActive,
|
||||
string channel,
|
||||
string channelId,
|
||||
string description,
|
||||
Collection<PlaylistEntry> entries,
|
||||
string id,
|
||||
string name,
|
||||
string thumbnailUrl, // TODO: Check if settable on Jellyfin
|
||||
PlaylistType type)
|
||||
{
|
||||
this.IsActive = isActive;
|
||||
this.Channel = channel;
|
||||
this.ChannelId = channelId;
|
||||
this.Description = description;
|
||||
this.Entries = entries;
|
||||
this.Id = id;
|
||||
this.Name = name;
|
||||
this.ThumbnailUrl = thumbnailUrl;
|
||||
this.Type = type;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the playlist is active or not.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_active")]
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist channel name.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_channel")]
|
||||
public string Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist channel YouTube id.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_channel_id")]
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist description.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist videos.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_entries")]
|
||||
public Collection<PlaylistEntry> Entries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist YouTube id.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist name.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist thumbnail URL.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "playlist_thumbnail")]
|
||||
public string ThumbnailUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the playlist type.
|
||||
/// </summary>
|
||||
/// <value>One of the <see cref="PlaylistType"/> values indicating the playlist's type.</value>
|
||||
[JsonProperty(PropertyName = "playlist_type")]
|
||||
public PlaylistType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a string of the playlist's key fields for logging.
|
||||
/// </summary>
|
||||
/// <returns>A string containing the playlist id, name, and entries count.</returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return "PlaylistId: " + this.Id +
|
||||
", Name: " + this.Name +
|
||||
", EntriesCount: " + this.Entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using ICU4N.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API playlist entries data.
|
||||
/// </summary>
|
||||
public class PlaylistEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaylistEntry"/> class.
|
||||
/// </summary>
|
||||
/// <param name="youtubeId">Video YouTube id.</param>
|
||||
/// <param name="title">Video title.</param>
|
||||
/// <param name="uploader">Video uploader.</param>
|
||||
/// <param name="index">Video index in the playlist.</param>
|
||||
/// <param name="isDownloaded">A value indicating whether the video has been already downloaded or not.</param>
|
||||
public PlaylistEntry(
|
||||
string youtubeId,
|
||||
string title,
|
||||
string uploader,
|
||||
int index,
|
||||
bool isDownloaded)
|
||||
{
|
||||
this.YoutubeId = youtubeId;
|
||||
this.Title = title;
|
||||
this.Uploader = uploader;
|
||||
this.Index = index;
|
||||
this.IsDownloaded = isDownloaded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video YouTube id.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "youtube_id")]
|
||||
public string YoutubeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video title.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video uploader.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "uploader")]
|
||||
public string Uploader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video index in the playlist.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "idx")]
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the video has been already downloaded or not.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "downloaded")]
|
||||
public bool IsDownloaded { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,10 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
/// Gets or sets the contained data object.
|
||||
/// </summary>
|
||||
public T? Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pagination info object.
|
||||
/// </summary>
|
||||
public PaginationInfo? Paginate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +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;
|
||||
|
||||
@@ -64,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));
|
||||
@@ -72,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>
|
||||
@@ -94,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));
|
||||
@@ -102,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>
|
||||
@@ -132,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)
|
||||
{
|
||||
@@ -146,5 +150,190 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
|
||||
return pong;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends video playback progress to TubeArchivist.
|
||||
/// </summary>
|
||||
/// <param name="videoId">Video id.</param>
|
||||
/// <param name="progress">Progress in seconds.</param>
|
||||
/// <returns>The response <see cref="HttpStatusCode"/>.</returns>
|
||||
public async Task<HttpStatusCode> SetProgress(string videoId, long progress)
|
||||
{
|
||||
var progressEndpoint = $"/api/video/{videoId}/progress/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + progressEndpoint));
|
||||
var body = JsonConvert.SerializeObject(new Progress(progress));
|
||||
|
||||
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
|
||||
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends video playback progress to TubeArchivist.
|
||||
/// </summary>
|
||||
/// <param name="videoId">Video id.</param>
|
||||
/// <returns>Video playback progress data.</returns>
|
||||
public async Task<Progress?> GetProgress(string videoId)
|
||||
{
|
||||
Progress? progress = null;
|
||||
|
||||
var video = await GetVideo(videoId).ConfigureAwait(true);
|
||||
if (video != null)
|
||||
{
|
||||
progress = new Progress((long)video.Player.Position);
|
||||
_logger.LogDebug("{Message}", $"Retrieved progress {video.Player.Position}");
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// <returns>The response <see cref="HttpStatusCode"/>.</returns>
|
||||
public async Task<HttpStatusCode> SetWatchedStatus(string itemId, bool isWatched)
|
||||
{
|
||||
var watchedEndpoint = $"/api/watched/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + watchedEndpoint));
|
||||
var body = JsonConvert.SerializeObject(new Watched(itemId, isWatched));
|
||||
|
||||
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
|
||||
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the playlists from TubeArchivist.
|
||||
/// </summary>
|
||||
/// <returns>A task.</returns>
|
||||
public async Task<ISet<Playlist>?> GetPlaylists()
|
||||
{
|
||||
ResponseContainer<ISet<Playlist>?>? playlists = null;
|
||||
|
||||
var playlistsEndpoint = "/api/playlist/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + playlistsEndpoint));
|
||||
var response = await client.GetAsync(url).ConfigureAwait(true);
|
||||
while (response.StatusCode == HttpStatusCode.Moved)
|
||||
{
|
||||
url = response.Headers.Location;
|
||||
_logger.LogInformation("{Message}", "Received redirect to: " + url);
|
||||
response = await client.GetAsync(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + url)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
_logger.LogInformation("{Message}", url + ": " + response.StatusCode);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
|
||||
playlists = JsonConvert.DeserializeObject<ResponseContainer<ISet<Playlist>?>>(rawData);
|
||||
if (playlists?.Paginate != null)
|
||||
{
|
||||
var lastPage = playlists.Paginate.LastPage;
|
||||
_logger.LogInformation("Pagination info: Current page {CurrentPage} / Last page {LastPage}, Total hits: {TotalHits}", playlists.Paginate.CurrentPage, playlists.Paginate.LastPage, playlists.Paginate.TotalHits);
|
||||
|
||||
while (playlists.Paginate.CurrentPage < lastPage)
|
||||
{
|
||||
var nextPage = playlists.Paginate.CurrentPage + 1;
|
||||
var pagedUrl = new Uri(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + playlistsEndpoint + "?page=" + nextPage));
|
||||
response = await client.GetAsync(pagedUrl).ConfigureAwait(true);
|
||||
while (response.StatusCode == HttpStatusCode.Moved)
|
||||
{
|
||||
url = response.Headers.Location;
|
||||
_logger.LogInformation("{Message}", "Received redirect to: " + url);
|
||||
response = await client.GetAsync(Utils.SanitizeUrl(Plugin.Instance?.Configuration.TubeArchivistUrl + url)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
_logger.LogInformation("{Message}", pagedUrl + ": " + response.StatusCode);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
|
||||
var nextPagePlaylists = JsonConvert.DeserializeObject<ResponseContainer<ISet<Playlist>?>>(rawData);
|
||||
if (nextPagePlaylists?.Data != null)
|
||||
{
|
||||
foreach (var playlist in nextPagePlaylists.Data)
|
||||
{
|
||||
playlists.Data?.Add(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPagePlaylists?.Paginate != null)
|
||||
{
|
||||
playlists.Paginate = nextPagePlaylists.Paginate;
|
||||
_logger.LogInformation("Pagination info: Current page {CurrentPage} / Last page {LastPage}, Total hits: {TotalHits}", playlists.Paginate.CurrentPage, playlists.Paginate.LastPage, playlists.Paginate.TotalHits);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogCritical("Failed to retrieve page {PageNumber} of playlists during pagination.", nextPage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return playlists?.Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new custom playlist.
|
||||
/// </summary>
|
||||
/// <param name="creationRequest">Playlist creation request.</param>
|
||||
/// <returns>The created <see cref="Playlist"/>.</returns>
|
||||
public async Task<Playlist?> CreateCustomPlaylist(CustomPlaylistCreation creationRequest)
|
||||
{
|
||||
Playlist? playlist = null;
|
||||
var customPlaylistEndpoint = $"/api/playlist/custom/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint));
|
||||
var body = JsonConvert.SerializeObject(creationRequest);
|
||||
_logger.LogInformation("{Message}", body);
|
||||
|
||||
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
|
||||
_logger.LogInformation("POST {Message}", url + ": " + response.StatusCode);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string rawData = await response.Content.ReadAsStringAsync().ConfigureAwait(true);
|
||||
playlist = JsonConvert.DeserializeObject<Playlist>(rawData);
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new custom playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">Playlist id.</param>
|
||||
/// <param name="entryAction">Playlist creation request.</param>
|
||||
/// <returns>The response <see cref="HttpStatusCode"/>.</returns>
|
||||
public async Task<HttpStatusCode> CustomPlaylistEntryAction(string playlistId, CustomPlaylistEntryAction entryAction)
|
||||
{
|
||||
var customPlaylistEndpoint = $"/api/playlist/custom/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + customPlaylistEndpoint + playlistId));
|
||||
var body = JsonConvert.SerializeObject(entryAction);
|
||||
_logger.LogDebug("CustomPlaylistEntryAction body: {Message}", body);
|
||||
|
||||
var response = await client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json")).ConfigureAwait(true);
|
||||
_logger.LogDebug("Response code: {Message}", response.StatusCode);
|
||||
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a playlist.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">Playlist id.</param>
|
||||
/// <returns>Whether the playlist has been deleted successfully or not.</returns>
|
||||
public async Task<bool> DeletePlaylist(string playlistId)
|
||||
{
|
||||
var deletePlaylistEndpoint = $"/api/playlist/";
|
||||
var url = new Uri(Utils.SanitizeUrl(Plugin.Instance!.Configuration.TubeArchivistUrl + deletePlaylistEndpoint + playlistId));
|
||||
var response = await client.DeleteAsync(url).ConfigureAwait(true);
|
||||
_logger.LogDebug("Response code: {Message}", response.StatusCode);
|
||||
|
||||
return response.StatusCode == HttpStatusCode.NoContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API video player data.
|
||||
/// </summary>
|
||||
public class Player
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Player"/> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">Duration of the video.</param>
|
||||
/// <param name="isWatched">Whether the video is marked as watched.</param>
|
||||
/// <param name="position">Video watched seconds.</param>
|
||||
public Player(long duration, bool isWatched, double position)
|
||||
{
|
||||
Duration = duration;
|
||||
IsWatched = isWatched;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the duration of the video.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "duration")]
|
||||
public long Duration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the video is marked as watched.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "watched")]
|
||||
public bool IsWatched { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the video watched seconds.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "position")]
|
||||
public double Position { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
{
|
||||
/// <summary>
|
||||
/// A class representing TubeArchivist API video progress data.
|
||||
/// </summary>
|
||||
public class Progress
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Progress"/> class.
|
||||
/// </summary>
|
||||
/// <param name="position">Playback progress in seconds.</param>
|
||||
/// <param name="youtubeId">Video YouTube id.</param>
|
||||
/// <param name="userId">Playback progress user id.</param>
|
||||
[JsonConstructor]
|
||||
public Progress(
|
||||
long position,
|
||||
string? youtubeId,
|
||||
long? userId)
|
||||
{
|
||||
YoutubeId = youtubeId;
|
||||
UserId = userId;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Progress"/> class.
|
||||
/// </summary>
|
||||
/// <param name="position">Playback progress in seconds.</param>
|
||||
public Progress(long position)
|
||||
{
|
||||
Position = position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the video YouTube id.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "youtube_id")]
|
||||
public string? YoutubeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets playback progress user id.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "user_id")]
|
||||
public long? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets video playback progress (in seconds).
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "position")]
|
||||
public long Position { get; set; }
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Configuration;
|
||||
using Jellyfin.Plugin.TubeArchivistMetadata.Utilities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
@@ -27,6 +28,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
/// <param name="published">Video published date.</param>
|
||||
/// <param name="vidThumbUrl">Video thuumb image URL.</param>
|
||||
/// <param name="youtubeId">Video YouTube id.</param>
|
||||
/// <param name="player">Player info.</param>
|
||||
public Video(
|
||||
Channel channel,
|
||||
Collection<string> tags,
|
||||
@@ -34,7 +36,8 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
string description,
|
||||
DateTime published,
|
||||
string vidThumbUrl,
|
||||
string youtubeId)
|
||||
string youtubeId,
|
||||
Player player)
|
||||
{
|
||||
this.Channel = channel;
|
||||
this.Tags = tags;
|
||||
@@ -43,6 +46,7 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
this.Published = published;
|
||||
this.VidThumbUrl = vidThumbUrl;
|
||||
this.YoutubeId = youtubeId;
|
||||
Player = player;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,6 +92,12 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
[JsonProperty(PropertyName = "youtube_id")]
|
||||
public string YoutubeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Getsthe player related info.
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "player")]
|
||||
public Player Player { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts the TubeArchivist API video to a Jellyfin <see cref="RemoteSearchResult"/> object.
|
||||
/// </summary>
|
||||
@@ -117,6 +127,11 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.TubeArchivist
|
||||
Overview = Utils.FormatDescription(Description),
|
||||
SeasonName = Published.Year.ToString(CultureInfo.CurrentCulture),
|
||||
ParentIndexNumber = Published.Year,
|
||||
IndexNumber = Plugin.Instance?.Configuration?.EpisodeNumberingScheme switch
|
||||
{
|
||||
NumberingScheme.YYYYMMDD => (Published.Year * 10000) + (Published.Month * 100) + Published.Day,
|
||||
_ => null
|
||||
},
|
||||
SeriesName = Channel.Name,
|
||||
ProductionYear = Published.Year,
|
||||
PremiereDate = Published,
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -9,38 +9,86 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
|
||||
/// </summary>
|
||||
public static class Utils
|
||||
{
|
||||
private const string TAPlaylistIdRegex = @"^(.*)\((.*)\)$";
|
||||
private const string YTTAPlaylistNameFormatRegex = @"^(.*)\s\-\s(.*)\s\((.*)\)$";
|
||||
private const string TAPlaylistNameFormatRegex = @"^(.*)\s\((.*)\)$";
|
||||
|
||||
/// <summary>
|
||||
/// Sanitize the given URL.
|
||||
/// Sanitizes the given URL.
|
||||
/// </summary>
|
||||
/// <param name="inputUrl">An URL string.</param>
|
||||
/// <returns>The URL string without spaces, doubled slashes and with a trailing slash.</returns>
|
||||
public static string SanitizeUrl(string inputUrl)
|
||||
{
|
||||
// Extract the schema part
|
||||
Match schemaMatch = Regex.Match(inputUrl, @"^(?<schema>https?://)");
|
||||
if (string.IsNullOrWhiteSpace(inputUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Remove double slashes and spaces from the remaining part of the URL
|
||||
string cleanedPath = Regex.Replace(inputUrl.Substring(schemaMatch.Length), @"[/\s]+", "/");
|
||||
// Extract the schema part (http:// or https://)
|
||||
Match schemaMatch = Regex.Match(inputUrl, @"^(?<schema>https?://)", RegexOptions.IgnoreCase);
|
||||
|
||||
// If no schema found, treat whole string as the rest
|
||||
int schemaLength = schemaMatch.Success ? schemaMatch.Length : 0;
|
||||
|
||||
// Separate the main part from query (?) and fragment (#)
|
||||
string rest = inputUrl.Substring(schemaLength);
|
||||
string pathPart = rest;
|
||||
string queryAndFragment = string.Empty;
|
||||
|
||||
int qIndex = rest.IndexOf('?', StringComparison.Ordinal);
|
||||
int fIndex = rest.IndexOf('#', StringComparison.Ordinal);
|
||||
|
||||
int splitIndex = -1;
|
||||
if (qIndex >= 0 && fIndex >= 0)
|
||||
{
|
||||
splitIndex = Math.Min(qIndex, fIndex);
|
||||
}
|
||||
else if (qIndex >= 0)
|
||||
{
|
||||
splitIndex = qIndex;
|
||||
}
|
||||
else if (fIndex >= 0)
|
||||
{
|
||||
splitIndex = fIndex;
|
||||
}
|
||||
|
||||
if (splitIndex >= 0)
|
||||
{
|
||||
pathPart = rest.Substring(0, splitIndex);
|
||||
queryAndFragment = rest.Substring(splitIndex);
|
||||
}
|
||||
|
||||
// Remove double slashes and spaces from the path part
|
||||
string cleanedPath = Regex.Replace(pathPart, @"[/\s]+", "/");
|
||||
|
||||
// Remove slashes at the start
|
||||
cleanedPath = cleanedPath.TrimStart('/');
|
||||
|
||||
// Add a trailing slash if not already present
|
||||
cleanedPath = cleanedPath.TrimEnd('/') + "/";
|
||||
// Add a trailing slash only when there are no query or fragment parts
|
||||
if (string.IsNullOrEmpty(queryAndFragment))
|
||||
{
|
||||
cleanedPath = cleanedPath.TrimEnd('/') + "/";
|
||||
}
|
||||
|
||||
// Combine the schema and cleaned path
|
||||
string cleanedUrl = schemaMatch.Groups["schema"].Value + cleanedPath;
|
||||
// Combine the schema and cleaned path and re-append query/fragment
|
||||
string cleanedUrl = (schemaMatch.Success ? schemaMatch.Groups["schema"].Value : string.Empty) + cleanedPath + queryAndFragment;
|
||||
|
||||
return cleanedUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format episodes and series descriptions replacing newlines with br tags.
|
||||
/// Formats episodes and series descriptions replacing newlines with br tags.
|
||||
/// </summary>
|
||||
/// <param name="description">String to format.</param>
|
||||
/// <returns>A string with \n replaced by br tags.</returns>
|
||||
public static string FormatDescription(string description)
|
||||
{
|
||||
if (description == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var maxLength = 500;
|
||||
if (Plugin.Instance != null)
|
||||
{
|
||||
@@ -50,14 +98,15 @@ namespace Jellyfin.Plugin.TubeArchivistMetadata.Utilities
|
||||
if (description.Length > maxLength)
|
||||
{
|
||||
description = description.Substring(0, maxLength);
|
||||
description = description.Replace("\n", "<br>", System.StringComparison.CurrentCulture);
|
||||
}
|
||||
|
||||
description = description.Replace("\n", "<br>", System.StringComparison.CurrentCulture);
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get video name from file path on the disk.
|
||||
/// Gets video name from file path on the disk.
|
||||
/// </summary>
|
||||
/// <param name="path">File path on disk.</param>
|
||||
/// <returns>The video name.</returns>
|
||||
@@ -67,7 +116,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 +139,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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
|
||||
|
||||
<p>This plugin adds the metadata provider for <a href="https://www.tubearchivist.com/">TubeArchivist</a>, offering improved flexibility and native integration with Jellyfin compared to previous solutions.</p>
|
||||
@@ -19,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`
|
||||

|
||||

|
||||
|
||||
3. Go to the `Catalog` tab
|
||||
4. Find `TubeArchivistMetadata` in the `Metadata` section and install it
|
||||

|
||||
3. Go back to the catalog
|
||||
4. Find `TubeArchivistMetadata` and install it
|
||||

|
||||
5. Restart Jellyfin to apply the changes
|
||||
|
||||
### From ZIP in GitHub releases
|
||||
@@ -46,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>
|
||||
|
||||

|
||||
@@ -62,9 +76,58 @@ _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
|
||||
|
||||
|
||||
## Synchronization
|
||||
This plugin has different bidirectional sycnhronization features, that can be configured in the specific section in the plugin configuration page:
|
||||

|
||||
|
||||
### Playback synchronization
|
||||
<p>Starting from v1.3.1 this plugin offers playback progress and watched status bidirectional synchronization, but you can choose to enable only a one way synchronization (Jellyfin -> TubeArchivist or TubeArchivist -> Jellyfin) too.</p>
|
||||
|
||||
#### Jellyfin -> TubeArchivist playback synchronization
|
||||
<p>This kind of synchronization is done listening for progress and watched status changes while playing the videos for the specified users.<br>Furthermore, there is a task that runs at Jellyfin startup to synchronize the whole library.</p>
|
||||
<p>In the text field you can specify one Jellyfin username to synchronize data of to TubeArchivist.</p>
|
||||
|
||||
#### TubeArchivist -> Jellyfin playback synchronization
|
||||
<p>This kind of synchronization is done using a Jellyfin scheduled task that regularly synchronizes data from TubeArchivist API to Jellyfin.</p>
|
||||
<p>In the text field you can specify one or more Jellyfin usernames to update data for.</p>
|
||||
|
||||
### Playlists synchronization
|
||||
<p>Starting from v.1.4.1 this plugin offers playlists bidirectional synchronization, but you can choose to enable only a one way synchronization (Jellyfin -> TubeArchivist or TubeArchivist -> Jellyfin) too.</p>
|
||||
|
||||
#### Jellyfin -> TubeArchivist playlists synchronization
|
||||
<p>There is a task that retrieves playlists and recreates them on TubeArchivist with the videos in the same order. Please note that playlists can also have videos not beloging from TubeArchivist, they will be simply ignored, so you won't find them on TubeArchivist playlist.</p>
|
||||
<p>It is present also a setting to automatically delete playlists from TubeArchivist when they are no more available on Jellyfin.</p>
|
||||
|
||||
#### TubeArchivist -> Jellyfin playlists synchronization
|
||||
<p>There is a task that retrieves playlists from TubeArchivist and recreates them on Jellyfin with videos in the same order.</p>
|
||||
<p>It is present, also in this case, a setting to automatically delete playlists from Jellyfin when they are no more present on TubeArchivist, but beware that the will be deleted also if they contain videos not beloning to TubeArchivist.</p>
|
||||
|
||||
> [!CAUTION]
|
||||
> Pay attention when you enable the automatic deletion options, be sure that is your wanted behavior, especially when playlists contain also other videos not belonging from TubeArchivist, playlists removed won't be available again, there's no undo!
|
||||
|
||||
|
||||
## Tasks intervals
|
||||
<p>Since many of the feature are implemented as background tasks periodically executing, in the `Tasks intervals` section you will find the settings to adjust this period in seconds.<br>
|
||||
Keep in mind that Jellyfin lowest accepted period is of 1 minute (60 seconds) and the lower is the interval the higher will be the resources consuption on your system.</p>
|
||||
<p>Here are the configurable intervals:</p>
|
||||
|
||||

|
||||
|
||||
## Episode numbering
|
||||
|
||||
<p>There are different ways to number the episodes as they are configured in Jellyfin.<br>
|
||||
This changes the number after E in S--E-- (for example S2024E100 for episode number 100 of season 2024).</p>
|
||||
|
||||

|
||||
|
||||
The options correlate with:
|
||||
- Default - leave the numbering to what Jellyfin does by default (this is what the plugin has always done)
|
||||
- YYYYMMDD - numbers the episode by the year, month, day (e.g. 20250804 for a video published on the 4th of August 2025)
|
||||
|
||||
## Build
|
||||
|
||||
1. To build this plugin you will need [.Net 6.x](https://dotnet.microsoft.com/download/dotnet/6.0).
|
||||
1. To build this plugin you will need [.Net 9.x](https://dotnet.microsoft.com/download/dotnet/9.0).
|
||||
|
||||
2. Build plugin with following command
|
||||
```
|
||||
|
||||
+5
-4
@@ -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.2.5.0"
|
||||
targetAbi: "10.8.0.0"
|
||||
framework: "net6.0"
|
||||
version: "1.4.4.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,5 @@ owner: "DarkFighterLuke"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.TubeArchivistMetadata.dll"
|
||||
changelog: >
|
||||
Ping the TA API to validate connection
|
||||
Handle TubeArchivist playlists pagination
|
||||
Handle prematurely ended response errors
|
||||
|
||||
+113
-9
@@ -8,13 +8,117 @@
|
||||
"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.3.0",
|
||||
"changelog": "Introduce episode numbering alternative schema\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.3/tubearchivistmetadata_1.4.3.0.zip",
|
||||
"checksum": "297d4f15af1132939f43c905b1ad393e",
|
||||
"timestamp": "2025-11-13T19:31:49Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.2.0",
|
||||
"changelog": "Introduce episode numbering alternative schema\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.2/tubearchivistmetadata_1.4.2.0.zip",
|
||||
"checksum": "8df142d2f1f0c0dffa302a567b40ef84",
|
||||
"timestamp": "2025-10-23T20:27:32Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.1.0",
|
||||
"changelog": "Introduce bidirectional playlists synchronization\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.1/tubearchivistmetadata_1.4.1.0.zip",
|
||||
"checksum": "2dc0b9140472a4f783f093a7ee425493",
|
||||
"timestamp": "2025-10-22T07:00:27Z"
|
||||
},
|
||||
{
|
||||
"version": "1.4.0.0",
|
||||
"changelog": "Introduce bidirectional playlists synchronization\n",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.4.0/tubearchivistmetadata_1.4.0.0.zip",
|
||||
"checksum": "4b81ab60022b532a64bb5eeb7be67dcc",
|
||||
"timestamp": "2025-10-22T06:57:53Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.7.0",
|
||||
"changelog": "Fix JF->TA Sync\n",
|
||||
"targetAbi": "10.10.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.7/tubearchivistmetadata_1.3.7.0.zip",
|
||||
"checksum": "60d8e9f134912f907dccd30b531da4f6",
|
||||
"timestamp": "2025-05-27T18:36:55Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.6.0",
|
||||
"changelog": "Fix TA auth header\n",
|
||||
"targetAbi": "10.10.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.6/tubearchivistmetadata_1.3.6.0.zip",
|
||||
"checksum": "513cde64268867d0047009eb2d986054",
|
||||
"timestamp": "2025-03-10T22:01:13Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.5.0",
|
||||
"changelog": "Adapt to the new TA API changes Fix JF crash when setting watched status with TA offline\n",
|
||||
"targetAbi": "10.10.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.5/tubearchivistmetadata_1.3.5.0.zip",
|
||||
"checksum": "2536622c0ec73e6401860929f24849f7",
|
||||
"timestamp": "2025-03-10T17:47:43Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.4.0",
|
||||
"changelog": "Merge workaround for JF crashing when scanning Series pull request by wolffshots\n",
|
||||
"targetAbi": "10.10.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.4/tubearchivistmetadata_1.3.4.0.zip",
|
||||
"checksum": "1ee6041139a6fdc3cecc8b2a427c60a8",
|
||||
"timestamp": "2024-11-04T21:30:24Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.3.0",
|
||||
"changelog": "Add compatibility with Jellyfin 10.10\n",
|
||||
"targetAbi": "10.10.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.3/tubearchivistmetadata_1.3.3.0.zip",
|
||||
"checksum": "ee6431d8bdaf844672d167b6f3ee0a73",
|
||||
"timestamp": "2024-11-04T20:41:46Z"
|
||||
},
|
||||
{
|
||||
"version": "1.3.2.0",
|
||||
"changelog": "Add 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",
|
||||
"targetAbi": "10.9.1.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.3.0/tubearchivistmetadata_1.3.0.0.zip",
|
||||
"checksum": "e1fde6f75a10a5f17bc9cc5814949180",
|
||||
"timestamp": "2024-05-14T20:20:13Z"
|
||||
},
|
||||
{
|
||||
"version": "1.2.5.0",
|
||||
"changelog": "Ping the TA API to validate connection\n",
|
||||
"targetAbi": "10.8.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.2.5/tubearchivistmetadata_1.2.5.0.zip",
|
||||
"checksum": "e4a0d076ffd84c976ad66dde91dfb840",
|
||||
"timestamp": "2024-05-14T17:37:17Z"
|
||||
},
|
||||
{
|
||||
"version": "1.2.4.0",
|
||||
"changelog": "Fix issues with directory separators\n",
|
||||
"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",
|
||||
@@ -22,7 +126,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",
|
||||
@@ -30,7 +134,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",
|
||||
@@ -38,7 +142,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",
|
||||
@@ -46,7 +150,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",
|
||||
@@ -54,7 +158,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",
|
||||
@@ -62,7 +166,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",
|
||||
@@ -70,8 +174,8 @@
|
||||
"targetAbi": "10.8.0.0",
|
||||
"sourceUrl": "https://github.com/tubearchivist/tubearchivist-jf-plugin/releases/download/v1.0.0/tubearchivistmetadata_1.0.0.0.zip",
|
||||
"checksum": "444f8980671de494b1875ee7d1657bcf",
|
||||
"timestamp": "2024-03-11 16:02:00"
|
||||
"timestamp": "2024-03-11T16:02:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user