First POC with podcasts library
This commit is contained in:
parent
7a9dbdafcc
commit
4679b77d1a
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -15,5 +15,5 @@
|
|||||||
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
|
"jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin",
|
||||||
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
||||||
// The name of the plugin
|
// The name of the plugin
|
||||||
"pluginName": "Jellyfin.Plugin.Template",
|
"pluginName": "Jellyfin.Plugin.Jellypod",
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
#
|
#
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Jellypod", "Jellyfin.Plugin.Jellypod\Jellyfin.Plugin.Jellypod.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
365
Jellyfin.Plugin.Jellypod/Api/JellypodController.cs
Normal file
365
Jellyfin.Plugin.Jellypod/Api/JellypodController.cs
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Mime;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for Jellypod podcast management.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("Jellypod")]
|
||||||
|
[Produces(MediaTypeNames.Application.Json)]
|
||||||
|
public class JellypodController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<JellypodController> _logger;
|
||||||
|
private readonly IRssFeedService _rssFeedService;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly IPodcastDownloadService _downloadService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="JellypodController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="rssFeedService">RSS feed service.</param>
|
||||||
|
/// <param name="storageService">Storage service.</param>
|
||||||
|
/// <param name="downloadService">Download service.</param>
|
||||||
|
public JellypodController(
|
||||||
|
ILogger<JellypodController> logger,
|
||||||
|
IRssFeedService rssFeedService,
|
||||||
|
IPodcastStorageService storageService,
|
||||||
|
IPodcastDownloadService downloadService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_rssFeedService = rssFeedService;
|
||||||
|
_storageService = storageService;
|
||||||
|
_downloadService = downloadService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all subscribed podcasts.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of podcasts.</returns>
|
||||||
|
[HttpGet("podcasts")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IEnumerable<Podcast>>> GetPodcasts()
|
||||||
|
{
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
return Ok(podcasts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a specific podcast by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Podcast ID.</param>
|
||||||
|
/// <returns>The podcast.</returns>
|
||||||
|
[HttpGet("podcasts/{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Podcast>> GetPodcast([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
||||||
|
return podcast != null ? Ok(podcast) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new podcast subscription.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Add podcast request.</param>
|
||||||
|
/// <returns>The created podcast.</returns>
|
||||||
|
[HttpPost("podcasts")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<Podcast>> AddPodcast([FromBody] AddPodcastRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.FeedUrl))
|
||||||
|
{
|
||||||
|
return BadRequest("Feed URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
var existingPodcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
var existing = existingPodcasts.FirstOrDefault(p =>
|
||||||
|
string.Equals(p.FeedUrl, request.FeedUrl, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return Conflict($"Already subscribed to this podcast: {existing.Title}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and validate the feed
|
||||||
|
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid or inaccessible RSS feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
podcast.AutoDownloadEnabled = request.AutoDownload ?? true;
|
||||||
|
await _storageService.AddPodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Download podcast artwork
|
||||||
|
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a podcast subscription.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Podcast ID.</param>
|
||||||
|
/// <param name="deleteFiles">Whether to delete downloaded files.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpDelete("podcasts/{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> DeletePodcast([FromRoute] Guid id, [FromQuery] bool deleteFiles = false)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteFiles)
|
||||||
|
{
|
||||||
|
foreach (var episode in podcast.Episodes.Where(e => e.Status == EpisodeStatus.Downloaded))
|
||||||
|
{
|
||||||
|
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a podcast's settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Podcast ID.</param>
|
||||||
|
/// <param name="request">Update request.</param>
|
||||||
|
/// <returns>The updated podcast.</returns>
|
||||||
|
[HttpPut("podcasts/{id}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Podcast>> UpdatePodcast([FromRoute] Guid id, [FromBody] UpdatePodcastRequest request)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AutoDownload.HasValue)
|
||||||
|
{
|
||||||
|
podcast.AutoDownloadEnabled = request.AutoDownload.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MaxEpisodesToKeep.HasValue)
|
||||||
|
{
|
||||||
|
podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
return Ok(podcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes a podcast feed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Podcast ID.</param>
|
||||||
|
/// <returns>The updated podcast.</returns>
|
||||||
|
[HttpPost("podcasts/{id}/refresh")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Podcast>> RefreshPodcast([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl).ConfigureAwait(false);
|
||||||
|
if (updatedPodcast == null)
|
||||||
|
{
|
||||||
|
return BadRequest("Failed to fetch podcast feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find new episodes
|
||||||
|
var existingGuids = podcast.Episodes
|
||||||
|
.Select(e => e.EpisodeGuid)
|
||||||
|
.Where(g => !string.IsNullOrEmpty(g))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var newEpisodes = updatedPodcast.Episodes
|
||||||
|
.Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var episode in newEpisodes)
|
||||||
|
{
|
||||||
|
episode.PodcastId = podcast.Id;
|
||||||
|
podcast.Episodes.Insert(0, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
podcast.LastUpdated = DateTime.UtcNow;
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Ok(podcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads multiple episodes from a podcast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">Podcast ID.</param>
|
||||||
|
/// <param name="request">Download request with count.</param>
|
||||||
|
/// <returns>Number of episodes queued.</returns>
|
||||||
|
[HttpPost("podcasts/{id}/download")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<DownloadResult>> DownloadEpisodes([FromRoute] Guid id, [FromBody] DownloadEpisodesRequest request)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = request.Count > 0 ? request.Count : 5;
|
||||||
|
var episodesToDownload = podcast.Episodes
|
||||||
|
.Where(e => e.Status == EpisodeStatus.Available)
|
||||||
|
.OrderByDescending(e => e.PublishedDate)
|
||||||
|
.Take(count)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var episode in episodesToDownload)
|
||||||
|
{
|
||||||
|
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Queued {Count} episodes for download from {Podcast}", episodesToDownload.Count, podcast.Title);
|
||||||
|
|
||||||
|
return Ok(new DownloadResult { QueuedCount = episodesToDownload.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads a specific episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">Podcast ID.</param>
|
||||||
|
/// <param name="episodeId">Episode ID.</param>
|
||||||
|
/// <returns>Accepted status.</returns>
|
||||||
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/download")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> DownloadEpisode([FromRoute] Guid podcastId, [FromRoute] Guid episodeId)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
|
||||||
|
if (podcast == null || episode == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes an episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">Podcast ID.</param>
|
||||||
|
/// <param name="episodeId">Episode ID.</param>
|
||||||
|
/// <param name="deleteFile">Whether to delete the downloaded file.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpDelete("podcasts/{podcastId}/episodes/{episodeId}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> DeleteEpisode(
|
||||||
|
[FromRoute] Guid podcastId,
|
||||||
|
[FromRoute] Guid episodeId,
|
||||||
|
[FromQuery] bool deleteFile = true)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
|
||||||
|
if (podcast == null || episode == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteFile && episode.Status == EpisodeStatus.Downloaded)
|
||||||
|
{
|
||||||
|
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
podcast.Episodes.Remove(episode);
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Previews a podcast feed without subscribing.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Preview request.</param>
|
||||||
|
/// <returns>The podcast info.</returns>
|
||||||
|
[HttpPost("preview")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<Podcast>> PreviewFeed([FromBody] PreviewFeedRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.FeedUrl))
|
||||||
|
{
|
||||||
|
return BadRequest("Feed URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
|
||||||
|
return podcast != null ? Ok(podcast) : BadRequest("Invalid or inaccessible RSS feed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes duplicate podcast subscriptions, keeping the first one.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Number of duplicates removed.</returns>
|
||||||
|
[HttpPost("cleanup")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<object>> CleanupDuplicates()
|
||||||
|
{
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var duplicatesToRemove = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
if (seenUrls.Contains(podcast.FeedUrl))
|
||||||
|
{
|
||||||
|
duplicatesToRemove.Add(podcast.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
seenUrls.Add(podcast.FeedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in duplicatesToRemove)
|
||||||
|
{
|
||||||
|
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Removed {Count} duplicate podcast subscriptions", duplicatesToRemove.Count);
|
||||||
|
|
||||||
|
return Ok(new { RemovedCount = duplicatesToRemove.Count });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs
Normal file
20
Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to add a new podcast.
|
||||||
|
/// </summary>
|
||||||
|
public class AddPodcastRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the RSS feed URL.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string FeedUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to enable auto-download.
|
||||||
|
/// </summary>
|
||||||
|
public bool? AutoDownload { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to download episodes from a podcast.
|
||||||
|
/// </summary>
|
||||||
|
public class DownloadEpisodesRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of episodes to download.
|
||||||
|
/// </summary>
|
||||||
|
public int Count { get; set; } = 5;
|
||||||
|
}
|
||||||
12
Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs
Normal file
12
Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a download request.
|
||||||
|
/// </summary>
|
||||||
|
public class DownloadResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of episodes queued for download.
|
||||||
|
/// </summary>
|
||||||
|
public int QueuedCount { get; set; }
|
||||||
|
}
|
||||||
15
Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs
Normal file
15
Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to preview a feed.
|
||||||
|
/// </summary>
|
||||||
|
public class PreviewFeedRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the RSS feed URL.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public string FeedUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
17
Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs
Normal file
17
Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update a podcast.
|
||||||
|
/// </summary>
|
||||||
|
public class UpdatePodcastRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to enable auto-download.
|
||||||
|
/// </summary>
|
||||||
|
public bool? AutoDownload { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum episodes to keep.
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxEpisodesToKeep { get; set; }
|
||||||
|
}
|
||||||
85
Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs
Normal file
85
Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for proxying podcast audio streams.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("Jellypod/Stream")]
|
||||||
|
public class StreamProxyController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<StreamProxyController> _logger;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||||
|
public StreamProxyController(
|
||||||
|
ILogger<StreamProxyController> logger,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxies an audio stream from a remote URL.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url">Base64-encoded URL to stream.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The audio stream.</returns>
|
||||||
|
[HttpGet("{url}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetStream([FromRoute] string url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Decode the URL from base64
|
||||||
|
var decodedUrl = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(url));
|
||||||
|
_logger.LogDebug("Proxying audio stream from: {Url}", decodedUrl);
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
|
||||||
|
// Make a streaming request
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, decodedUrl);
|
||||||
|
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch audio stream: {StatusCode}", response.StatusCode);
|
||||||
|
return StatusCode((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content type
|
||||||
|
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/mpeg";
|
||||||
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (contentLength.HasValue)
|
||||||
|
{
|
||||||
|
Response.Headers["Content-Length"] = contentLength.Value.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response.Headers["Accept-Ranges"] = "bytes";
|
||||||
|
|
||||||
|
return File(stream, contentType, enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error proxying audio stream");
|
||||||
|
return StatusCode(500, "Failed to proxy audio stream");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
472
Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs
Normal file
472
Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Channels;
|
||||||
|
using MediaBrowser.Controller.Providers;
|
||||||
|
using MediaBrowser.Model.Channels;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.MediaInfo;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Channels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellypod channel for browsing and playing podcast episodes.
|
||||||
|
/// </summary>
|
||||||
|
public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallback
|
||||||
|
{
|
||||||
|
private readonly ILogger<JellypodChannel> _logger;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly IRssFeedService _rssFeedService;
|
||||||
|
private readonly IPodcastDownloadService _downloadService;
|
||||||
|
private readonly IServerApplicationHost _appHost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="JellypodChannel"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="loggerFactory">The logger factory.</param>
|
||||||
|
/// <param name="storageService">The podcast storage service.</param>
|
||||||
|
/// <param name="rssFeedService">The RSS feed service.</param>
|
||||||
|
/// <param name="downloadService">The download service.</param>
|
||||||
|
/// <param name="appHost">The server application host.</param>
|
||||||
|
public JellypodChannel(
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
IPodcastStorageService storageService,
|
||||||
|
IRssFeedService rssFeedService,
|
||||||
|
IPodcastDownloadService downloadService,
|
||||||
|
IServerApplicationHost appHost)
|
||||||
|
{
|
||||||
|
_logger = loggerFactory.CreateLogger<JellypodChannel>();
|
||||||
|
_storageService = storageService;
|
||||||
|
_rssFeedService = rssFeedService;
|
||||||
|
_downloadService = downloadService;
|
||||||
|
_appHost = appHost;
|
||||||
|
_logger.LogDebug("JellypodChannel initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Podcasts";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Browse and listen to your podcast subscriptions";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string DataVersion => "1.0";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string HomePageUrl => string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InternalChannelFeatures GetChannelFeatures()
|
||||||
|
{
|
||||||
|
return new InternalChannelFeatures
|
||||||
|
{
|
||||||
|
ContentTypes = new List<ChannelMediaContentType>
|
||||||
|
{
|
||||||
|
ChannelMediaContentType.Podcast
|
||||||
|
},
|
||||||
|
MediaTypes = new List<ChannelMediaType>
|
||||||
|
{
|
||||||
|
ChannelMediaType.Audio
|
||||||
|
},
|
||||||
|
SupportsSortOrderToggle = true,
|
||||||
|
DefaultSortFields = new List<ChannelItemSortField>
|
||||||
|
{
|
||||||
|
ChannelItemSortField.PremiereDate,
|
||||||
|
ChannelItemSortField.DateCreated,
|
||||||
|
ChannelItemSortField.Name
|
||||||
|
},
|
||||||
|
AutoRefreshLevels = 1,
|
||||||
|
MaxPageSize = 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("GetChannelImage called with type: {Type}", type);
|
||||||
|
|
||||||
|
var assembly = GetType().Assembly;
|
||||||
|
var resourceName = "Jellyfin.Plugin.Jellypod.Images.channel-icon.jpg";
|
||||||
|
|
||||||
|
// Log available resources for debugging
|
||||||
|
var resourceNames = assembly.GetManifestResourceNames();
|
||||||
|
_logger.LogInformation("Available embedded resources: {Resources}", string.Join(", ", resourceNames));
|
||||||
|
|
||||||
|
var stream = assembly.GetManifestResourceStream(resourceName);
|
||||||
|
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Found channel icon, stream length: {Length}", stream.Length);
|
||||||
|
return Task.FromResult(new DynamicImageResponse
|
||||||
|
{
|
||||||
|
HasImage = true,
|
||||||
|
Stream = stream,
|
||||||
|
Format = MediaBrowser.Model.Drawing.ImageFormat.Jpg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Channel icon resource not found: {ResourceName}", resourceName);
|
||||||
|
return Task.FromResult(new DynamicImageResponse
|
||||||
|
{
|
||||||
|
HasImage = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||||
|
{
|
||||||
|
return new List<ImageType>
|
||||||
|
{
|
||||||
|
ImageType.Primary,
|
||||||
|
ImageType.Thumb
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
|
||||||
|
|
||||||
|
return new ChannelItemResult
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
TotalRecordCount = items.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
|
||||||
|
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Root level - show all subscribed podcasts as folders
|
||||||
|
if (string.IsNullOrEmpty(folderId))
|
||||||
|
{
|
||||||
|
return await GetPodcastFoldersAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Podcast folder - show episodes
|
||||||
|
if (Guid.TryParse(folderId, out var podcastId))
|
||||||
|
{
|
||||||
|
return await GetPodcastEpisodesAsync(podcastId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<ChannelItemInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ChannelItemInfo>> GetPodcastFoldersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = new List<ChannelItemInfo>();
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
var episodeCount = podcast.Episodes.Count;
|
||||||
|
var downloadedCount = podcast.Episodes.Count(e => e.Status == EpisodeStatus.Downloaded);
|
||||||
|
|
||||||
|
items.Add(new ChannelItemInfo
|
||||||
|
{
|
||||||
|
Id = podcast.Id.ToString("N"),
|
||||||
|
Name = podcast.Title,
|
||||||
|
Overview = podcast.Description,
|
||||||
|
ImageUrl = podcast.ImageUrl,
|
||||||
|
Type = ChannelItemType.Folder,
|
||||||
|
FolderType = ChannelFolderType.Container,
|
||||||
|
DateCreated = podcast.DateAdded,
|
||||||
|
DateModified = podcast.LastUpdated
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogDebug("Added podcast folder: {Title} ({EpisodeCount} episodes, {DownloadedCount} downloaded)", podcast.Title, episodeCount, downloadedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Returning {Count} podcast folders", items.Count);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ChannelItemInfo>> GetPodcastEpisodesAsync(Guid podcastId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = new List<ChannelItemInfo>();
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Podcast {PodcastId} not found", podcastId);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort episodes by published date, newest first
|
||||||
|
var episodes = podcast.Episodes
|
||||||
|
.OrderByDescending(e => e.PublishedDate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var episode in episodes)
|
||||||
|
{
|
||||||
|
// Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo
|
||||||
|
// which allows us to download-on-demand and return proper local file paths
|
||||||
|
items.Add(new ChannelItemInfo
|
||||||
|
{
|
||||||
|
Id = episode.Id.ToString("N"),
|
||||||
|
Name = episode.Title,
|
||||||
|
Overview = episode.Description,
|
||||||
|
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
|
||||||
|
Type = ChannelItemType.Media,
|
||||||
|
ContentType = ChannelMediaContentType.Podcast,
|
||||||
|
MediaType = ChannelMediaType.Audio,
|
||||||
|
DateCreated = episode.PublishedDate,
|
||||||
|
PremiereDate = episode.PublishedDate,
|
||||||
|
RunTimeTicks = episode.Duration?.Ticks,
|
||||||
|
SeriesName = podcast.Title
|
||||||
|
// MediaSources intentionally omitted - see GetChannelItemMediaInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Returning {Count} episodes for podcast {PodcastTitle}", items.Count, podcast.Title);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaSourceInfo CreateMediaSource(Podcast podcast, Episode episode)
|
||||||
|
{
|
||||||
|
// If episode is downloaded, use local file; otherwise proxy through our endpoint
|
||||||
|
var isLocal = episode.Status == EpisodeStatus.Downloaded && !string.IsNullOrEmpty(episode.LocalFilePath);
|
||||||
|
|
||||||
|
string path;
|
||||||
|
MediaProtocol protocol;
|
||||||
|
|
||||||
|
if (isLocal)
|
||||||
|
{
|
||||||
|
path = episode.LocalFilePath!;
|
||||||
|
protocol = MediaProtocol.File;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Use proxy URL to avoid ffprobe issues with remote URLs
|
||||||
|
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(episode.AudioUrl));
|
||||||
|
var serverUrl = _appHost.GetSmartApiUrl(string.Empty);
|
||||||
|
path = $"{serverUrl}/Jellypod/Stream/{encodedUrl}";
|
||||||
|
protocol = MediaProtocol.Http;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = GetContainerFromUrl(episode.AudioUrl);
|
||||||
|
var codec = GetCodecFromContainer(container);
|
||||||
|
|
||||||
|
// Create audio stream info - required for Jellyfin to play without probing
|
||||||
|
var audioStream = new MediaStream
|
||||||
|
{
|
||||||
|
Type = MediaStreamType.Audio,
|
||||||
|
Index = 0,
|
||||||
|
Codec = codec,
|
||||||
|
Channels = 2,
|
||||||
|
SampleRate = 44100,
|
||||||
|
BitRate = 128000,
|
||||||
|
IsDefault = true,
|
||||||
|
Language = "und"
|
||||||
|
};
|
||||||
|
|
||||||
|
return new MediaSourceInfo
|
||||||
|
{
|
||||||
|
Id = episode.Id.ToString("N"),
|
||||||
|
Name = episode.Title,
|
||||||
|
Path = path,
|
||||||
|
Protocol = protocol,
|
||||||
|
Container = container,
|
||||||
|
Type = MediaSourceType.Default,
|
||||||
|
IsRemote = !isLocal,
|
||||||
|
SupportsDirectPlay = true,
|
||||||
|
SupportsDirectStream = true,
|
||||||
|
SupportsTranscoding = false,
|
||||||
|
SupportsProbing = false,
|
||||||
|
RequiresOpening = false,
|
||||||
|
RequiresClosing = false,
|
||||||
|
RunTimeTicks = episode.Duration?.Ticks,
|
||||||
|
Size = episode.FileSizeBytes,
|
||||||
|
Bitrate = 128000,
|
||||||
|
MediaStreams = new List<MediaStream> { audioStream },
|
||||||
|
DefaultAudioStreamIndex = 0,
|
||||||
|
ReadAtNativeFramerate = false,
|
||||||
|
AnalyzeDurationMs = 0,
|
||||||
|
IsInfiniteStream = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCodecFromContainer(string container)
|
||||||
|
{
|
||||||
|
return container switch
|
||||||
|
{
|
||||||
|
"mp3" => "mp3",
|
||||||
|
"m4a" => "aac",
|
||||||
|
"ogg" => "vorbis",
|
||||||
|
"opus" => "opus",
|
||||||
|
_ => "mp3"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetContainerFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (path.EndsWith(".mp3", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "mp3";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.EndsWith(".m4a", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "m4a";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.EndsWith(".ogg", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "ogg";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.EndsWith(".opus", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "opus";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore URL parsing errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return "mp3"; // Default to mp3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? GetCacheKey(string? userId)
|
||||||
|
{
|
||||||
|
// Use 5-minute time buckets for cache key
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 5) * 5, 0);
|
||||||
|
return timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsEnabledFor(string userId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("GetChannelItemMediaInfo called for id: {Id}", id);
|
||||||
|
|
||||||
|
// Parse the episode ID
|
||||||
|
if (!Guid.TryParse(id, out var episodeId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid episode ID format: {Id}", id);
|
||||||
|
return Enumerable.Empty<MediaSourceInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the episode across all podcasts
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
if (episode != null)
|
||||||
|
{
|
||||||
|
var currentPodcast = podcast;
|
||||||
|
var currentEpisode = episode;
|
||||||
|
|
||||||
|
// Download episode if not already downloaded
|
||||||
|
if (currentEpisode.Status != EpisodeStatus.Downloaded || string.IsNullOrEmpty(currentEpisode.LocalFilePath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Downloading episode on demand: {Title}", currentEpisode.Title);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _downloadService.DownloadEpisodeAsync(currentPodcast, currentEpisode, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
// Reload the episode to get updated status
|
||||||
|
var reloadedPodcast = await _storageService.GetPodcastAsync(currentPodcast.Id).ConfigureAwait(false);
|
||||||
|
var reloadedEpisode = reloadedPodcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
if (reloadedEpisode == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Episode disappeared after download: {Id}", id);
|
||||||
|
return Enumerable.Empty<MediaSourceInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEpisode = reloadedEpisode;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to download episode: {Title}", currentEpisode.Title);
|
||||||
|
return Enumerable.Empty<MediaSourceInfo>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaSource = CreateMediaSourceForLocalFile(currentEpisode);
|
||||||
|
_logger.LogDebug("Returning media source for episode: {Title}, Path: {Path}", currentEpisode.Title, mediaSource.Path);
|
||||||
|
return new[] { mediaSource };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Episode not found: {Id}", id);
|
||||||
|
return Enumerable.Empty<MediaSourceInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaSourceInfo CreateMediaSourceForLocalFile(Episode episode)
|
||||||
|
{
|
||||||
|
// This is for downloaded local files
|
||||||
|
var container = GetContainerFromUrl(episode.AudioUrl);
|
||||||
|
var codec = GetCodecFromContainer(container);
|
||||||
|
|
||||||
|
// Jellyfin requires MediaStreams with audio info for StreamBuilder.GetOptimalAudioStream
|
||||||
|
var audioStream = new MediaStream
|
||||||
|
{
|
||||||
|
Type = MediaStreamType.Audio,
|
||||||
|
Index = 0,
|
||||||
|
Codec = codec,
|
||||||
|
Channels = 2,
|
||||||
|
SampleRate = 44100,
|
||||||
|
BitRate = 128000,
|
||||||
|
IsDefault = true,
|
||||||
|
Language = "und"
|
||||||
|
};
|
||||||
|
|
||||||
|
return new MediaSourceInfo
|
||||||
|
{
|
||||||
|
Id = episode.Id.ToString("N"),
|
||||||
|
Name = episode.Title,
|
||||||
|
Path = episode.LocalFilePath!,
|
||||||
|
Protocol = MediaProtocol.File,
|
||||||
|
Container = container,
|
||||||
|
Type = MediaSourceType.Default,
|
||||||
|
IsRemote = false,
|
||||||
|
SupportsDirectPlay = true,
|
||||||
|
SupportsDirectStream = true,
|
||||||
|
SupportsTranscoding = true,
|
||||||
|
SupportsProbing = false, // Don't probe - we provide stream info
|
||||||
|
RequiresOpening = false,
|
||||||
|
RequiresClosing = false,
|
||||||
|
RunTimeTicks = episode.Duration?.Ticks,
|
||||||
|
Size = episode.FileSizeBytes,
|
||||||
|
Bitrate = 128000,
|
||||||
|
MediaStreams = new List<MediaStream> { audioStream },
|
||||||
|
DefaultAudioStreamIndex = 0,
|
||||||
|
ReadAtNativeFramerate = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin configuration for Jellypod.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginConfiguration : BasePluginConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public PluginConfiguration()
|
||||||
|
{
|
||||||
|
PodcastStoragePath = string.Empty;
|
||||||
|
UpdateIntervalHours = 6;
|
||||||
|
GlobalAutoDownloadEnabled = true;
|
||||||
|
MaxConcurrentDownloads = 2;
|
||||||
|
MaxEpisodesPerPodcast = 50;
|
||||||
|
CreatePodcastFolders = true;
|
||||||
|
DownloadNewEpisodesOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the path where podcasts will be stored.
|
||||||
|
/// Leave empty to use the default Jellyfin data path.
|
||||||
|
/// </summary>
|
||||||
|
public string PodcastStoragePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the update interval in hours for checking new episodes.
|
||||||
|
/// </summary>
|
||||||
|
public int UpdateIntervalHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto-download is enabled globally.
|
||||||
|
/// </summary>
|
||||||
|
public bool GlobalAutoDownloadEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum number of concurrent downloads.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxConcurrentDownloads { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum episodes to keep per podcast (0 = unlimited).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxEpisodesPerPodcast { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to create subfolders for each podcast.
|
||||||
|
/// </summary>
|
||||||
|
public bool CreatePodcastFolders { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to only download new episodes after subscription.
|
||||||
|
/// </summary>
|
||||||
|
public bool DownloadNewEpisodesOnly { get; set; }
|
||||||
|
}
|
||||||
383
Jellyfin.Plugin.Jellypod/Configuration/configPage.html
Normal file
383
Jellyfin.Plugin.Jellypod/Configuration/configPage.html
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Jellypod</title>
|
||||||
|
<style>
|
||||||
|
.podcast-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75em;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.podcast-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.podcast-image {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
max-width: 50px;
|
||||||
|
max-height: 50px;
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 50px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 1em;
|
||||||
|
background: #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.podcast-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.podcast-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
.podcast-meta {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.podcast-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.add-podcast-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.add-podcast-form .inputContainer {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.section-divider {
|
||||||
|
margin: 2em 0;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background: rgba(0,100,200,0.2);
|
||||||
|
border: 1px solid rgba(0,100,200,0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="JellypodConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
||||||
|
<div data-role="content">
|
||||||
|
<div class="content-primary">
|
||||||
|
<h2 class="sectionTitle">Jellypod Settings</h2>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Browse Podcasts:</strong> Your subscribed podcasts appear in the <em>Channels</em> section of Jellyfin's main menu.
|
||||||
|
Use this settings page to add/remove podcast subscriptions and configure download options.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="JellypodConfigForm">
|
||||||
|
<!-- Storage Settings -->
|
||||||
|
<div class="verticalSection">
|
||||||
|
<h3 class="sectionTitle">Storage</h3>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="PodcastStoragePath">
|
||||||
|
Download Storage Path
|
||||||
|
</label>
|
||||||
|
<input id="PodcastStoragePath" name="PodcastStoragePath" type="text" is="emby-input" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Path where downloaded episodes are stored. Leave empty for default location.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="CreatePodcastFolders" name="CreatePodcastFolders" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Create subfolders for each podcast</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Settings -->
|
||||||
|
<div class="verticalSection">
|
||||||
|
<h3 class="sectionTitle">Feed Updates</h3>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="UpdateIntervalHours">
|
||||||
|
Update Interval (hours)
|
||||||
|
</label>
|
||||||
|
<input id="UpdateIntervalHours" name="UpdateIntervalHours" type="number" is="emby-input" min="1" max="168" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
How often to check for new episodes (default: 6 hours)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Settings -->
|
||||||
|
<div class="verticalSection">
|
||||||
|
<h3 class="sectionTitle">Downloads</h3>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="GlobalAutoDownloadEnabled" name="GlobalAutoDownloadEnabled" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Automatically download new episodes</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Episodes can always be streamed directly. Enable this to also download them locally.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="MaxConcurrentDownloads">
|
||||||
|
Max Concurrent Downloads
|
||||||
|
</label>
|
||||||
|
<input id="MaxConcurrentDownloads" name="MaxConcurrentDownloads" type="number" is="emby-input" min="1" max="5" />
|
||||||
|
</div>
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="MaxEpisodesPerPodcast">
|
||||||
|
Max Episodes Per Podcast
|
||||||
|
</label>
|
||||||
|
<input id="MaxEpisodesPerPodcast" name="MaxEpisodesPerPodcast" type="number" is="emby-input" min="0" />
|
||||||
|
<div class="fieldDescription">
|
||||||
|
Maximum episodes to keep downloaded per podcast (0 = unlimited)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||||
|
<span>Save Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="section-divider"></div>
|
||||||
|
|
||||||
|
<!-- Podcast Subscription Management -->
|
||||||
|
<div class="verticalSection">
|
||||||
|
<h2 class="sectionTitle">Podcast Subscriptions</h2>
|
||||||
|
|
||||||
|
<!-- Add Podcast Form -->
|
||||||
|
<div class="add-podcast-form">
|
||||||
|
<div class="inputContainer">
|
||||||
|
<label class="inputLabel inputLabelUnfocused" for="NewFeedUrl">
|
||||||
|
RSS Feed URL
|
||||||
|
</label>
|
||||||
|
<input id="NewFeedUrl" type="url" is="emby-input" placeholder="https://example.com/feed.xml" />
|
||||||
|
</div>
|
||||||
|
<button is="emby-button" type="button" id="btnAddPodcast" class="raised emby-button">
|
||||||
|
<span>Subscribe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podcast List -->
|
||||||
|
<div id="podcastList">
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
No podcast subscriptions yet. Add one above, then browse in Channels.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var JellypodConfig = {
|
||||||
|
pluginUniqueId: 'c713faf4-4e50-4e87-941a-1200178ed605'
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(JellypodConfig.pluginUniqueId).then(function (config) {
|
||||||
|
document.querySelector('#PodcastStoragePath').value = config.PodcastStoragePath || '';
|
||||||
|
document.querySelector('#UpdateIntervalHours').value = config.UpdateIntervalHours;
|
||||||
|
document.querySelector('#GlobalAutoDownloadEnabled').checked = config.GlobalAutoDownloadEnabled;
|
||||||
|
document.querySelector('#MaxConcurrentDownloads').value = config.MaxConcurrentDownloads;
|
||||||
|
document.querySelector('#MaxEpisodesPerPodcast').value = config.MaxEpisodesPerPodcast;
|
||||||
|
document.querySelector('#CreatePodcastFolders').checked = config.CreatePodcastFolders;
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPodcasts() {
|
||||||
|
console.log('Jellypod: Loading podcasts...');
|
||||||
|
ApiClient.fetch({
|
||||||
|
url: ApiClient.getUrl('Jellypod/podcasts'),
|
||||||
|
type: 'GET'
|
||||||
|
}).then(function(response) {
|
||||||
|
// ApiClient.fetch returns a Response object, need to parse JSON
|
||||||
|
return response.json();
|
||||||
|
}).then(function(podcasts) {
|
||||||
|
console.log('Jellypod: Received podcasts:', podcasts);
|
||||||
|
renderPodcasts(podcasts);
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Jellypod: Failed to load podcasts:', err);
|
||||||
|
renderPodcasts([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPodcasts(podcasts) {
|
||||||
|
var container = document.querySelector('#podcastList');
|
||||||
|
console.log('Jellypod: Rendering podcasts, count:', podcasts ? podcasts.length : 0);
|
||||||
|
|
||||||
|
if (!podcasts || podcasts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No podcast subscriptions yet. Add one above, then browse in Channels.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = podcasts.map(function(podcast) {
|
||||||
|
// Handle both PascalCase (C#) and camelCase (JSON) property names
|
||||||
|
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
|
||||||
|
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
|
||||||
|
var lastUpdatedStr = lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Never';
|
||||||
|
var podcastId = podcast.Id || podcast.id;
|
||||||
|
var podcastTitle = podcast.Title || podcast.title || 'Unknown';
|
||||||
|
var podcastImage = podcast.ImageUrl || podcast.imageUrl || '';
|
||||||
|
|
||||||
|
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
|
||||||
|
|
||||||
|
return '<div class="podcast-item" data-id="' + podcastId + '">' +
|
||||||
|
'<img class="podcast-image" src="' + podcastImage + '" alt="" onerror="this.style.display=\'none\'">' +
|
||||||
|
'<div class="podcast-info">' +
|
||||||
|
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
|
||||||
|
'<div class="podcast-meta">' +
|
||||||
|
episodeCount + ' episodes | Updated: ' + lastUpdatedStr +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="podcast-actions">' +
|
||||||
|
'<button is="emby-button" type="button" class="emby-button" onclick="refreshPodcast(\'' + podcastId + '\')" title="Refresh Feed">' +
|
||||||
|
'<span class="material-icons">refresh</span>' +
|
||||||
|
'</button>' +
|
||||||
|
'<button is="emby-button" type="button" class="emby-button" onclick="deletePodcast(\'' + podcastId + '\')" title="Unsubscribe">' +
|
||||||
|
'<span class="material-icons">delete</span>' +
|
||||||
|
'</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
console.log('Jellypod: Rendered HTML length:', html.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPodcast() {
|
||||||
|
var feedUrl = document.querySelector('#NewFeedUrl').value.trim();
|
||||||
|
if (!feedUrl) {
|
||||||
|
Dashboard.alert('Please enter a feed URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.fetch({
|
||||||
|
url: ApiClient.getUrl('Jellypod/podcasts'),
|
||||||
|
type: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify({ feedUrl: feedUrl })
|
||||||
|
}).then(function(podcast) {
|
||||||
|
document.querySelector('#NewFeedUrl').value = '';
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
loadPodcasts();
|
||||||
|
Dashboard.alert('Subscribed to: ' + podcast.title + '\n\nBrowse episodes in Channels > Podcasts');
|
||||||
|
}).catch(function(err) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
Dashboard.alert('Failed to subscribe. Please check the URL and try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.refreshPodcast = function(id) {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.fetch({
|
||||||
|
url: ApiClient.getUrl('Jellypod/podcasts/' + id + '/refresh'),
|
||||||
|
type: 'POST'
|
||||||
|
}).then(function() {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
loadPodcasts();
|
||||||
|
Dashboard.alert('Feed refreshed');
|
||||||
|
}).catch(function(err) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
Dashboard.alert('Failed to refresh feed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deletePodcast = function(id) {
|
||||||
|
console.log('Jellypod: deletePodcast called with id:', id);
|
||||||
|
// Use simple confirm since require(['confirm']) may not work in newer Jellyfin
|
||||||
|
if (confirm('Are you sure you want to unsubscribe from this podcast?')) {
|
||||||
|
console.log('Jellypod: User confirmed deletion');
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.fetch({
|
||||||
|
url: ApiClient.getUrl('Jellypod/podcasts/' + id + '?deleteFiles=true'),
|
||||||
|
type: 'DELETE'
|
||||||
|
}).then(function() {
|
||||||
|
console.log('Jellypod: Delete successful');
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
loadPodcasts();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Jellypod: Delete failed:', err);
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
Dashboard.alert('Failed to unsubscribe');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jellyfin uses 'viewshow' event for SPA navigation, not 'pageshow'
|
||||||
|
document.querySelector('#JellypodConfigPage').addEventListener('viewshow', function() {
|
||||||
|
console.log('Jellypod: viewshow event fired');
|
||||||
|
loadConfig();
|
||||||
|
loadPodcasts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also handle pageshow as fallback
|
||||||
|
document.querySelector('#JellypodConfigPage').addEventListener('pageshow', function() {
|
||||||
|
console.log('Jellypod: pageshow event fired');
|
||||||
|
loadConfig();
|
||||||
|
loadPodcasts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize immediately if the page is already visible (handles direct page load)
|
||||||
|
(function() {
|
||||||
|
console.log('Jellypod: Script loaded, checking if should initialize...');
|
||||||
|
// Small delay to ensure Jellyfin's framework is ready
|
||||||
|
setTimeout(function() {
|
||||||
|
var page = document.querySelector('#JellypodConfigPage');
|
||||||
|
if (page && page.offsetParent !== null) {
|
||||||
|
console.log('Jellypod: Page visible, initializing...');
|
||||||
|
loadConfig();
|
||||||
|
loadPodcasts();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.querySelector('#JellypodConfigForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
ApiClient.getPluginConfiguration(JellypodConfig.pluginUniqueId).then(function (config) {
|
||||||
|
config.PodcastStoragePath = document.querySelector('#PodcastStoragePath').value;
|
||||||
|
config.UpdateIntervalHours = parseInt(document.querySelector('#UpdateIntervalHours').value, 10);
|
||||||
|
config.GlobalAutoDownloadEnabled = document.querySelector('#GlobalAutoDownloadEnabled').checked;
|
||||||
|
config.MaxConcurrentDownloads = parseInt(document.querySelector('#MaxConcurrentDownloads').value, 10);
|
||||||
|
config.MaxEpisodesPerPodcast = parseInt(document.querySelector('#MaxEpisodesPerPodcast').value, 10);
|
||||||
|
config.CreatePodcastFolders = document.querySelector('#CreatePodcastFolders').checked;
|
||||||
|
ApiClient.updatePluginConfiguration(JellypodConfig.pluginUniqueId, config).then(function (result) {
|
||||||
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('#btnAddPodcast').addEventListener('click', addPodcast);
|
||||||
|
|
||||||
|
document.querySelector('#NewFeedUrl').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addPodcast();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg
Normal file
BIN
Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@ -1,13 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
|
<RootNamespace>Jellyfin.Plugin.Jellypod</RootNamespace>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -17,6 +18,8 @@
|
|||||||
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
|
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||||
|
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -28,6 +31,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
<None Remove="Images\channel-icon.jpg" />
|
||||||
|
<EmbeddedResource Include="Images\channel-icon.jpg" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
84
Jellyfin.Plugin.Jellypod/Models/Episode.cs
Normal file
84
Jellyfin.Plugin.Jellypod/Models/Episode.cs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a podcast episode.
|
||||||
|
/// </summary>
|
||||||
|
public class Episode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier for this episode.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the parent podcast.
|
||||||
|
/// </summary>
|
||||||
|
public Guid PodcastId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode description.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the URL to the audio file.
|
||||||
|
/// </summary>
|
||||||
|
public string AudioUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the local file path where the episode is stored.
|
||||||
|
/// </summary>
|
||||||
|
public string? LocalFilePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the file size in bytes.
|
||||||
|
/// </summary>
|
||||||
|
public long? FileSizeBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode duration.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan? Duration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the publish date.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime PublishedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date the episode was downloaded.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DownloadedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the download status.
|
||||||
|
/// </summary>
|
||||||
|
public EpisodeStatus Status { get; set; } = EpisodeStatus.Available;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the RSS GUID for deduplication.
|
||||||
|
/// </summary>
|
||||||
|
public string? EpisodeGuid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the season number if applicable.
|
||||||
|
/// </summary>
|
||||||
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode number if applicable.
|
||||||
|
/// </summary>
|
||||||
|
public int? EpisodeNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the episode-specific image URL.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
}
|
||||||
30
Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs
Normal file
30
Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the download status of a podcast episode.
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||||
|
public enum EpisodeStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Episode is known but not downloaded.
|
||||||
|
/// </summary>
|
||||||
|
Available,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Episode is currently being downloaded.
|
||||||
|
/// </summary>
|
||||||
|
Downloading,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Episode has been downloaded and is available locally.
|
||||||
|
/// </summary>
|
||||||
|
Downloaded,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download failed.
|
||||||
|
/// </summary>
|
||||||
|
Error
|
||||||
|
}
|
||||||
77
Jellyfin.Plugin.Jellypod/Models/Podcast.cs
Normal file
77
Jellyfin.Plugin.Jellypod/Models/Podcast.cs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a podcast subscription.
|
||||||
|
/// </summary>
|
||||||
|
public class Podcast
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique identifier for this podcast.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast description.
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the RSS feed URL.
|
||||||
|
/// </summary>
|
||||||
|
public string FeedUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast artwork URL.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast author/publisher.
|
||||||
|
/// </summary>
|
||||||
|
public string? Author { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast language.
|
||||||
|
/// </summary>
|
||||||
|
public string? Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the podcast category.
|
||||||
|
/// </summary>
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the last time the feed was updated.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastUpdated { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date this podcast was added.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime DateAdded { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether auto-download is enabled for this podcast.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoDownloadEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum episodes to keep (0 = unlimited).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxEpisodesToKeep { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of episodes.
|
||||||
|
/// </summary>
|
||||||
|
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||||
|
public Collection<Episode> Episodes { get; set; } = new();
|
||||||
|
}
|
||||||
22
Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs
Normal file
22
Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Container for podcast data persistence.
|
||||||
|
/// </summary>
|
||||||
|
public class PodcastDatabase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of subscribed podcasts.
|
||||||
|
/// </summary>
|
||||||
|
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
|
||||||
|
public Collection<Podcast> Podcasts { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the last time the database was saved.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastSaved { get; set; }
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Jellyfin.Plugin.Template.Configuration;
|
using Jellyfin.Plugin.Jellypod.Configuration;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Model.Plugins;
|
using MediaBrowser.Model.Plugins;
|
||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Template;
|
namespace Jellyfin.Plugin.Jellypod;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The main plugin.
|
/// The main plugin.
|
||||||
@ -26,10 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string Name => "Template";
|
public override string Name => "Jellypod";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
|
public override Guid Id => Guid.Parse("c713faf4-4e50-4e87-941a-1200178ed605");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current plugin instance.
|
/// Gets the current plugin instance.
|
||||||
33
Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs
Normal file
33
Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
using Jellyfin.Plugin.Jellypod.Channels;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Channels;
|
||||||
|
using MediaBrowser.Controller.Plugins;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers plugin services with the dependency injection container.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||||
|
{
|
||||||
|
// Register HTTP client
|
||||||
|
serviceCollection.AddHttpClient("Jellypod", client =>
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "Jellypod/1.0 (Jellyfin Podcast Plugin)");
|
||||||
|
client.Timeout = System.TimeSpan.FromMinutes(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register services
|
||||||
|
serviceCollection.AddSingleton<IRssFeedService, RssFeedService>();
|
||||||
|
serviceCollection.AddSingleton<IPodcastStorageService, PodcastStorageService>();
|
||||||
|
serviceCollection.AddSingleton<IPodcastDownloadService, PodcastDownloadService>();
|
||||||
|
|
||||||
|
// Register channel
|
||||||
|
serviceCollection.AddSingleton<IChannel, JellypodChannel>();
|
||||||
|
}
|
||||||
|
}
|
||||||
142
Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs
Normal file
142
Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
using MediaBrowser.Model.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.ScheduledTasks;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scheduled task that updates all podcast feeds and downloads new episodes.
|
||||||
|
/// </summary>
|
||||||
|
public class PodcastUpdateTask : IScheduledTask
|
||||||
|
{
|
||||||
|
private readonly ILogger<PodcastUpdateTask> _logger;
|
||||||
|
private readonly IRssFeedService _rssFeedService;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly IPodcastDownloadService _downloadService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PodcastUpdateTask"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="rssFeedService">RSS feed service.</param>
|
||||||
|
/// <param name="storageService">Storage service.</param>
|
||||||
|
/// <param name="downloadService">Download service.</param>
|
||||||
|
public PodcastUpdateTask(
|
||||||
|
ILogger<PodcastUpdateTask> logger,
|
||||||
|
IRssFeedService rssFeedService,
|
||||||
|
IPodcastStorageService storageService,
|
||||||
|
IPodcastDownloadService downloadService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_rssFeedService = rssFeedService;
|
||||||
|
_storageService = storageService;
|
||||||
|
_downloadService = downloadService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Update Podcast Feeds";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Key => "JellypodUpdateFeeds";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Description => "Checks all subscribed podcasts for new episodes and downloads them.";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Category => "Jellypod";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting podcast feed update task");
|
||||||
|
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
var totalPodcasts = podcasts.Count;
|
||||||
|
var processedCount = 0;
|
||||||
|
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Updating podcast: {Title}", podcast.Title);
|
||||||
|
|
||||||
|
var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (updatedPodcast != null)
|
||||||
|
{
|
||||||
|
// Find new episodes (by GUID)
|
||||||
|
var existingGuids = podcast.Episodes
|
||||||
|
.Select(e => e.EpisodeGuid)
|
||||||
|
.Where(g => !string.IsNullOrEmpty(g))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var newEpisodes = updatedPodcast.Episodes
|
||||||
|
.Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newEpisodes.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Found {Count} new episodes for {Title}", newEpisodes.Count, podcast.Title);
|
||||||
|
|
||||||
|
// Add new episodes to podcast
|
||||||
|
foreach (var episode in newEpisodes)
|
||||||
|
{
|
||||||
|
episode.PodcastId = podcast.Id;
|
||||||
|
podcast.Episodes.Insert(0, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
podcast.LastUpdated = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Auto-download if enabled
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config?.GlobalAutoDownloadEnabled == true && podcast.AutoDownloadEnabled)
|
||||||
|
{
|
||||||
|
foreach (var episode in newEpisodes)
|
||||||
|
{
|
||||||
|
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No new episodes for {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedCount++;
|
||||||
|
progress.Report((double)processedCount / totalPodcasts * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
|
_logger.LogInformation("Podcast feed update task completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
var intervalHours = config?.UpdateIntervalHours ?? 6;
|
||||||
|
|
||||||
|
return new[]
|
||||||
|
{
|
||||||
|
new TaskTriggerInfo
|
||||||
|
{
|
||||||
|
Type = TaskTriggerInfo.TriggerInterval,
|
||||||
|
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs
Normal file
49
Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for downloading podcast episodes.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPodcastDownloadService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Queues an episode for download.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast.</param>
|
||||||
|
/// <param name="episode">The episode to download.</param>
|
||||||
|
/// <returns>Task representing the queue operation.</returns>
|
||||||
|
Task QueueDownloadAsync(Podcast podcast, Episode episode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads an episode immediately.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast.</param>
|
||||||
|
/// <param name="episode">The episode to download.</param>
|
||||||
|
/// <param name="progress">Optional progress reporter.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The local file path of the downloaded episode.</returns>
|
||||||
|
Task<string> DownloadEpisodeAsync(
|
||||||
|
Podcast podcast,
|
||||||
|
Episode episode,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a downloaded episode file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="episode">The episode whose file to delete.</param>
|
||||||
|
/// <returns>Task representing the delete operation.</returns>
|
||||||
|
Task DeleteEpisodeFileAsync(Episode episode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads podcast artwork.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Task representing the download operation.</returns>
|
||||||
|
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
60
Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs
Normal file
60
Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for storing and retrieving podcast data.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPodcastStorageService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all subscribed podcasts.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of all podcasts.</returns>
|
||||||
|
Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a podcast by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The podcast ID.</param>
|
||||||
|
/// <returns>The podcast, or null if not found.</returns>
|
||||||
|
Task<Podcast?> GetPodcastAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new podcast subscription.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast to add.</param>
|
||||||
|
/// <returns>Task representing the operation.</returns>
|
||||||
|
Task AddPodcastAsync(Podcast podcast);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing podcast.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The podcast to update.</param>
|
||||||
|
/// <returns>Task representing the operation.</returns>
|
||||||
|
Task UpdatePodcastAsync(Podcast podcast);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a podcast subscription.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The podcast ID to delete.</param>
|
||||||
|
/// <returns>Task representing the operation.</returns>
|
||||||
|
Task DeletePodcastAsync(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the local file path for an episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcast">The parent podcast.</param>
|
||||||
|
/// <param name="episode">The episode.</param>
|
||||||
|
/// <returns>The file path where the episode should be stored.</returns>
|
||||||
|
string GetEpisodeFilePath(Podcast podcast, Episode episode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the storage path for podcasts.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The base path for podcast storage.</returns>
|
||||||
|
string GetStoragePath();
|
||||||
|
}
|
||||||
19
Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs
Normal file
19
Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for fetching and parsing podcast RSS feeds.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRssFeedService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches and parses a podcast from an RSS feed URL.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="feedUrl">The RSS feed URL.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The parsed podcast with episodes, or null if parsing failed.</returns>
|
||||||
|
Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
248
Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs
Normal file
248
Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for downloading podcast episodes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<PodcastDownloadService> _logger;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly ConcurrentQueue<(Podcast Podcast, Episode Episode)> _downloadQueue = new();
|
||||||
|
private readonly SemaphoreSlim _downloadSemaphore;
|
||||||
|
private int _isProcessing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PodcastDownloadService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||||
|
/// <param name="storageService">Storage service.</param>
|
||||||
|
public PodcastDownloadService(
|
||||||
|
ILogger<PodcastDownloadService> logger,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IPodcastStorageService storageService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_storageService = storageService;
|
||||||
|
|
||||||
|
var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2;
|
||||||
|
_downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task QueueDownloadAsync(Podcast podcast, Episode episode)
|
||||||
|
{
|
||||||
|
_downloadQueue.Enqueue((podcast, episode));
|
||||||
|
_logger.LogDebug("Queued download: {PodcastTitle} - {EpisodeTitle}", podcast.Title, episode.Title);
|
||||||
|
|
||||||
|
// Start processing if not already running
|
||||||
|
if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) == 0)
|
||||||
|
{
|
||||||
|
_ = ProcessQueueAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string> DownloadEpisodeAsync(
|
||||||
|
Podcast podcast,
|
||||||
|
Episode episode,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
|
||||||
|
var directory = Path.GetDirectoryName(filePath);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, filePath);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
episode.Status = EpisodeStatus.Downloading;
|
||||||
|
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
using var response = await httpClient.GetAsync(
|
||||||
|
episode.AudioUrl,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var totalBytes = response.Content.Headers.ContentLength ?? -1;
|
||||||
|
|
||||||
|
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
long totalRead;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
totalRead = 0L;
|
||||||
|
int bytesRead;
|
||||||
|
|
||||||
|
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||||
|
{
|
||||||
|
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
|
||||||
|
totalRead += bytesRead;
|
||||||
|
|
||||||
|
if (totalBytes > 0)
|
||||||
|
{
|
||||||
|
progress?.Report((double)totalRead / totalBytes * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await fileStream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await contentStream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.LocalFilePath = filePath;
|
||||||
|
episode.Status = EpisodeStatus.Downloaded;
|
||||||
|
episode.DownloadedDate = DateTime.UtcNow;
|
||||||
|
episode.FileSizeBytes = totalRead;
|
||||||
|
|
||||||
|
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
|
||||||
|
|
||||||
|
// Update the podcast in storage
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
episode.Status = EpisodeStatus.Error;
|
||||||
|
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DeleteEpisodeFileAsync(Episode episode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(episode.LocalFilePath))
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(episode.LocalFilePath))
|
||||||
|
{
|
||||||
|
File.Delete(episode.LocalFilePath);
|
||||||
|
_logger.LogInformation("Deleted episode file: {Path}", episode.LocalFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.LocalFilePath = null;
|
||||||
|
episode.Status = EpisodeStatus.Available;
|
||||||
|
episode.DownloadedDate = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete episode file: {Path}", episode.LocalFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(podcast.ImageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
if (config?.CreatePodcastFolders != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var basePath = _storageService.GetStoragePath();
|
||||||
|
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
|
||||||
|
var artworkPath = Path.Combine(podcastFolder, "folder.jpg");
|
||||||
|
|
||||||
|
if (File.Exists(artworkPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(podcastFolder);
|
||||||
|
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
var imageBytes = await httpClient.GetByteArrayAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Downloaded artwork for podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to download artwork for podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessQueueAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_downloadQueue.TryDequeue(out var item))
|
||||||
|
{
|
||||||
|
await _downloadSemaphore.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DownloadEpisodeAsync(item.Podcast, item.Episode).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to process queued download: {Title}", item.Episode.Title);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_downloadSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _isProcessing, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
|
||||||
|
return result.Length > 100 ? result.Substring(0, 100).Trim() : result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_downloadSemaphore.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
269
Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs
Normal file
269
Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for storing and retrieving podcast data.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly ILogger<PodcastStorageService> _logger;
|
||||||
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
|
private readonly SemaphoreSlim _dbLock = new(1, 1);
|
||||||
|
private PodcastDatabase? _cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="applicationPaths">Application paths.</param>
|
||||||
|
public PodcastStorageService(ILogger<PodcastStorageService> logger, IApplicationPaths applicationPaths)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_applicationPaths = applicationPaths;
|
||||||
|
_logger.LogInformation("Jellypod database path: {Path}", DatabasePath);
|
||||||
|
_logger.LogInformation("PluginConfigurationsPath: {Path}", applicationPaths.PluginConfigurationsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DatabasePath => Path.Combine(
|
||||||
|
_applicationPaths.PluginConfigurationsPath,
|
||||||
|
"Jellypod",
|
||||||
|
"podcasts.json");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseAsync().ConfigureAwait(false);
|
||||||
|
return db.Podcasts.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Podcast?> GetPodcastAsync(Guid id)
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseAsync().ConfigureAwait(false);
|
||||||
|
return db.Podcasts.FirstOrDefault(p => p.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task AddPodcastAsync(Podcast podcast)
|
||||||
|
{
|
||||||
|
await _dbLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
|
||||||
|
db.Podcasts.Add(podcast);
|
||||||
|
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Added podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dbLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task UpdatePodcastAsync(Podcast podcast)
|
||||||
|
{
|
||||||
|
await _dbLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
|
||||||
|
var existing = db.Podcasts.FirstOrDefault(p => p.Id == podcast.Id);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
var index = db.Podcasts.IndexOf(existing);
|
||||||
|
db.Podcasts[index] = podcast;
|
||||||
|
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
|
||||||
|
_logger.LogDebug("Updated podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dbLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeletePodcastAsync(Guid id)
|
||||||
|
{
|
||||||
|
await _dbLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
|
||||||
|
var podcast = db.Podcasts.FirstOrDefault(p => p.Id == id);
|
||||||
|
if (podcast != null)
|
||||||
|
{
|
||||||
|
db.Podcasts.Remove(podcast);
|
||||||
|
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Deleted podcast: {Title}", podcast.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dbLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetEpisodeFilePath(Podcast podcast, Episode episode)
|
||||||
|
{
|
||||||
|
var basePath = GetStoragePath();
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
|
||||||
|
var safePodcastTitle = SanitizeFileName(podcast.Title);
|
||||||
|
var safeEpisodeTitle = SanitizeFileName(episode.Title);
|
||||||
|
var extension = GetAudioExtension(episode.AudioUrl);
|
||||||
|
|
||||||
|
// Format: YYYY-MM-DD - Episode Title.mp3
|
||||||
|
var datePrefix = episode.PublishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
var fileName = $"{datePrefix} - {safeEpisodeTitle}{extension}";
|
||||||
|
|
||||||
|
if (config?.CreatePodcastFolders == true)
|
||||||
|
{
|
||||||
|
return Path.Combine(basePath, safePodcastTitle, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(basePath, $"{safePodcastTitle} - {fileName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetStoragePath()
|
||||||
|
{
|
||||||
|
var config = Plugin.Instance?.Configuration;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(config?.PodcastStoragePath))
|
||||||
|
{
|
||||||
|
return config.PodcastStoragePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(_applicationPaths.DataPath, "Podcasts");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PodcastDatabase> LoadDatabaseAsync()
|
||||||
|
{
|
||||||
|
await _dbLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await LoadDatabaseInternalAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dbLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PodcastDatabase> LoadDatabaseInternalAsync()
|
||||||
|
{
|
||||||
|
if (_cache != null)
|
||||||
|
{
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(DatabasePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
|
||||||
|
_cache = new PodcastDatabase();
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Loading database from {Path}", DatabasePath);
|
||||||
|
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Read {Length} characters from database file", json.Length);
|
||||||
|
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
|
||||||
|
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
|
||||||
|
_cache = new PodcastDatabase();
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveDatabaseInternalAsync(PodcastDatabase db)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(DatabasePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.LastSaved = DateTime.UtcNow;
|
||||||
|
var json = JsonSerializer.Serialize(db, JsonOptions);
|
||||||
|
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
|
||||||
|
_cache = db;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save podcast database");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (result.Length > 100)
|
||||||
|
{
|
||||||
|
result = result.Substring(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAudioExtension(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var path = uri.AbsolutePath;
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(extension) &&
|
||||||
|
(extension.Equals(".mp3", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
extension.Equals(".m4a", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
extension.Equals(".ogg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
extension.Equals(".opus", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return extension.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore URL parsing errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".mp3";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_dbLock.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
220
Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs
Normal file
220
Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.ServiceModel.Syndication;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for fetching and parsing podcast RSS feeds.
|
||||||
|
/// </summary>
|
||||||
|
public class RssFeedService : IRssFeedService
|
||||||
|
{
|
||||||
|
private static readonly XNamespace ItunesNs = "http://www.itunes.com/dtds/podcast-1.0.dtd";
|
||||||
|
private static readonly XNamespace ContentNs = "http://purl.org/rss/1.0/modules/content/";
|
||||||
|
|
||||||
|
private readonly ILogger<RssFeedService> _logger;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RssFeedService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||||
|
public RssFeedService(ILogger<RssFeedService> logger, IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
using var response = await httpClient.GetAsync(feedUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
using var reader = XmlReader.Create(stream);
|
||||||
|
var feed = SyndicationFeed.Load(reader);
|
||||||
|
|
||||||
|
var podcast = new Podcast
|
||||||
|
{
|
||||||
|
FeedUrl = feedUrl,
|
||||||
|
Title = feed.Title?.Text ?? "Unknown Podcast",
|
||||||
|
Description = StripHtml(feed.Description?.Text ?? string.Empty),
|
||||||
|
ImageUrl = GetItunesImage(feed) ?? feed.ImageUrl?.ToString(),
|
||||||
|
Author = GetItunesAuthor(feed),
|
||||||
|
Language = feed.Language,
|
||||||
|
LastUpdated = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse episodes
|
||||||
|
foreach (var item in feed.Items)
|
||||||
|
{
|
||||||
|
var episode = ParseEpisode(item, podcast.Id);
|
||||||
|
if (episode != null)
|
||||||
|
{
|
||||||
|
podcast.Episodes.Add(episode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetched podcast '{Title}' with {Count} episodes", podcast.Title, podcast.Episodes.Count);
|
||||||
|
return podcast;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch podcast from {FeedUrl}", feedUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Episode? ParseEpisode(SyndicationItem item, Guid podcastId)
|
||||||
|
{
|
||||||
|
// Find audio enclosure
|
||||||
|
var enclosure = item.Links.FirstOrDefault(l =>
|
||||||
|
string.Equals(l.RelationshipType, "enclosure", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(l.MediaType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
l.Uri?.ToString().EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
l.Uri?.ToString().EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) == true));
|
||||||
|
|
||||||
|
if (enclosure == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Episode
|
||||||
|
{
|
||||||
|
PodcastId = podcastId,
|
||||||
|
Title = item.Title?.Text ?? "Untitled Episode",
|
||||||
|
Description = StripHtml(item.Summary?.Text ?? GetContentEncoded(item) ?? string.Empty),
|
||||||
|
AudioUrl = enclosure.Uri.ToString(),
|
||||||
|
FileSizeBytes = enclosure.Length > 0 ? enclosure.Length : null,
|
||||||
|
PublishedDate = item.PublishDate.UtcDateTime,
|
||||||
|
EpisodeGuid = item.Id ?? enclosure.Uri.ToString(),
|
||||||
|
Duration = GetItunesDuration(item),
|
||||||
|
SeasonNumber = GetItunesSeason(item),
|
||||||
|
EpisodeNumber = GetItunesEpisode(item),
|
||||||
|
ImageUrl = GetItunesEpisodeImage(item)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetItunesImage(SyndicationFeed feed)
|
||||||
|
{
|
||||||
|
var imageElement = feed.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
if (imageElement != null)
|
||||||
|
{
|
||||||
|
var element = imageElement.GetObject<XElement>();
|
||||||
|
return element.Attribute("href")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetItunesAuthor(SyndicationFeed feed)
|
||||||
|
{
|
||||||
|
var authorElement = feed.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "author" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
return authorElement?.GetObject<XElement>()?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan? GetItunesDuration(SyndicationItem item)
|
||||||
|
{
|
||||||
|
var durationElement = item.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "duration" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
if (durationElement == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationStr = durationElement.GetObject<XElement>()?.Value;
|
||||||
|
if (string.IsNullOrEmpty(durationStr))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration can be in formats: HH:MM:SS, MM:SS, or just seconds
|
||||||
|
var parts = durationStr.Split(':');
|
||||||
|
return parts.Length switch
|
||||||
|
{
|
||||||
|
3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m) && int.TryParse(parts[2], out var s)
|
||||||
|
=> new TimeSpan(h, m, s),
|
||||||
|
2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var s)
|
||||||
|
=> new TimeSpan(0, m, s),
|
||||||
|
1 when int.TryParse(parts[0], out var s)
|
||||||
|
=> TimeSpan.FromSeconds(s),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetItunesSeason(SyndicationItem item)
|
||||||
|
{
|
||||||
|
var seasonElement = item.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "season" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
var value = seasonElement?.GetObject<XElement>()?.Value;
|
||||||
|
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var season) ? season : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetItunesEpisode(SyndicationItem item)
|
||||||
|
{
|
||||||
|
var episodeElement = item.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "episode" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
var value = episodeElement?.GetObject<XElement>()?.Value;
|
||||||
|
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var episode) ? episode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetItunesEpisodeImage(SyndicationItem item)
|
||||||
|
{
|
||||||
|
var imageElement = item.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
|
||||||
|
|
||||||
|
if (imageElement != null)
|
||||||
|
{
|
||||||
|
var element = imageElement.GetObject<XElement>();
|
||||||
|
return element.Attribute("href")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetContentEncoded(SyndicationItem item)
|
||||||
|
{
|
||||||
|
var contentElement = item.ElementExtensions
|
||||||
|
.FirstOrDefault(e => e.OuterName == "encoded" && e.OuterNamespace == ContentNs.NamespaceName);
|
||||||
|
|
||||||
|
return contentElement?.GetObject<XElement>()?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripHtml(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple HTML stripping - remove tags
|
||||||
|
var result = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", string.Empty);
|
||||||
|
// Decode common HTML entities
|
||||||
|
result = result.Replace(" ", " ", StringComparison.Ordinal)
|
||||||
|
.Replace("&", "&", StringComparison.Ordinal)
|
||||||
|
.Replace("<", "<", StringComparison.Ordinal)
|
||||||
|
.Replace(">", ">", StringComparison.Ordinal)
|
||||||
|
.Replace(""", "\"", StringComparison.Ordinal);
|
||||||
|
return result.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,57 +0,0 @@
|
|||||||
using MediaBrowser.Model.Plugins;
|
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.Template.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The configuration options.
|
|
||||||
/// </summary>
|
|
||||||
public enum SomeOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Option one.
|
|
||||||
/// </summary>
|
|
||||||
OneOption,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Second option.
|
|
||||||
/// </summary>
|
|
||||||
AnotherOption
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Plugin configuration.
|
|
||||||
/// </summary>
|
|
||||||
public class PluginConfiguration : BasePluginConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public PluginConfiguration()
|
|
||||||
{
|
|
||||||
// set default options here
|
|
||||||
Options = SomeOptions.AnotherOption;
|
|
||||||
TrueFalseSetting = true;
|
|
||||||
AnInteger = 2;
|
|
||||||
AString = "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a value indicating whether some true or false setting is enabled..
|
|
||||||
/// </summary>
|
|
||||||
public bool TrueFalseSetting { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an integer setting.
|
|
||||||
/// </summary>
|
|
||||||
public int AnInteger { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets a string setting.
|
|
||||||
/// </summary>
|
|
||||||
public string AString { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an enum option.
|
|
||||||
/// </summary>
|
|
||||||
public SomeOptions Options { get; set; }
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Template</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="TemplateConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
||||||
<div data-role="content">
|
|
||||||
<div class="content-primary">
|
|
||||||
<form id="TemplateConfigForm">
|
|
||||||
<div class="selectContainer">
|
|
||||||
<label class="selectLabel" for="Options">Several Options</label>
|
|
||||||
<select is="emby-select" id="Options" name="Options" class="emby-select-withcolor emby-select">
|
|
||||||
<option id="optOneOption" value="OneOption">One Option</option>
|
|
||||||
<option id="optAnotherOption" value="AnotherOption">Another Option</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="AnInteger">An Integer</label>
|
|
||||||
<input id="AnInteger" name="AnInteger" type="number" is="emby-input" min="0" />
|
|
||||||
<div class="fieldDescription">A Description</div>
|
|
||||||
</div>
|
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
||||||
<label class="emby-checkbox-label">
|
|
||||||
<input id="TrueFalseSetting" name="TrueFalseCheckBox" type="checkbox" is="emby-checkbox" />
|
|
||||||
<span>A Checkbox</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="inputContainer">
|
|
||||||
<label class="inputLabel inputLabelUnfocused" for="AString">A String</label>
|
|
||||||
<input id="AString" name="AString" type="text" is="emby-input" />
|
|
||||||
<div class="fieldDescription">Another Description</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var TemplateConfig = {
|
|
||||||
pluginUniqueId: 'eb5d7894-8eef-4b36-aa6f-5d124e828ce1'
|
|
||||||
};
|
|
||||||
|
|
||||||
document.querySelector('#TemplateConfigPage')
|
|
||||||
.addEventListener('pageshow', function() {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
|
||||||
document.querySelector('#Options').value = config.Options;
|
|
||||||
document.querySelector('#AnInteger').value = config.AnInteger;
|
|
||||||
document.querySelector('#TrueFalseSetting').checked = config.TrueFalseSetting;
|
|
||||||
document.querySelector('#AString').value = config.AString;
|
|
||||||
Dashboard.hideLoadingMsg();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelector('#TemplateConfigForm')
|
|
||||||
.addEventListener('submit', function(e) {
|
|
||||||
Dashboard.showLoadingMsg();
|
|
||||||
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
|
|
||||||
config.Options = document.querySelector('#Options').value;
|
|
||||||
config.AnInteger = document.querySelector('#AnInteger').value;
|
|
||||||
config.TrueFalseSetting = document.querySelector('#TrueFalseSetting').checked;
|
|
||||||
config.AString = document.querySelector('#AString').value;
|
|
||||||
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
|
|
||||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
282
Jellypod.html
Normal file
282
Jellypod.html
Normal file
File diff suppressed because one or more lines are too long
16
build.yaml
16
build.yaml
@ -1,16 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: "Template"
|
name: "Jellypod"
|
||||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
guid: "c713faf4-4e50-4e87-941a-1200178ed605"
|
||||||
version: "1.0.0.0"
|
version: "1.0.0.0"
|
||||||
targetAbi: "10.9.0.0"
|
targetAbi: "10.9.0.0"
|
||||||
framework: "net8.0"
|
framework: "net8.0"
|
||||||
overview: "Short description about your plugin"
|
overview: "Podcast management plugin for Jellyfin"
|
||||||
description: >
|
description: >
|
||||||
This is a longer description that can span more than one
|
Jellypod allows you to subscribe to podcast RSS feeds, automatically download
|
||||||
line and include details about your plugin.
|
episodes, and manage your podcast library within Jellyfin. Episodes are stored
|
||||||
|
as standard audio files and integrate with Jellyfin's built-in audio player.
|
||||||
category: "General"
|
category: "General"
|
||||||
owner: "jellyfin"
|
owner: "jellyfin"
|
||||||
artifacts:
|
artifacts:
|
||||||
- "Jellyfin.Plugin.Template.dll"
|
- "Jellyfin.Plugin.Jellypod.dll"
|
||||||
|
- "System.ServiceModel.Syndication.dll"
|
||||||
changelog: >
|
changelog: >
|
||||||
changelog
|
Initial release
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user