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",
|
||||
"jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin",
|
||||
// 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
|
||||
#
|
||||
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
|
||||
Global
|
||||
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,22 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Template</RootNamespace>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.Jellypod</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>enable</Nullable>
|
||||
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
|
||||
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" >
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -28,6 +31,8 @@
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<None Remove="Images\channel-icon.jpg" />
|
||||
<EmbeddedResource Include="Images\channel-icon.jpg" />
|
||||
</ItemGroup>
|
||||
|
||||
</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.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.Template.Configuration;
|
||||
using Jellyfin.Plugin.Jellypod.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Template;
|
||||
namespace Jellyfin.Plugin.Jellypod;
|
||||
|
||||
/// <summary>
|
||||
/// The main plugin.
|
||||
@ -26,10 +26,10 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "Template";
|
||||
public override string Name => "Jellypod";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1");
|
||||
public override Guid Id => Guid.Parse("c713faf4-4e50-4e87-941a-1200178ed605");
|
||||
|
||||
/// <summary>
|
||||
/// 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"
|
||||
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
|
||||
name: "Jellypod"
|
||||
guid: "c713faf4-4e50-4e87-941a-1200178ed605"
|
||||
version: "1.0.0.0"
|
||||
targetAbi: "10.9.0.0"
|
||||
framework: "net8.0"
|
||||
overview: "Short description about your plugin"
|
||||
overview: "Podcast management plugin for Jellyfin"
|
||||
description: >
|
||||
This is a longer description that can span more than one
|
||||
line and include details about your plugin.
|
||||
Jellypod allows you to subscribe to podcast RSS feeds, automatically download
|
||||
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"
|
||||
owner: "jellyfin"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.Template.dll"
|
||||
- "Jellyfin.Plugin.Jellypod.dll"
|
||||
- "System.ServiceModel.Syndication.dll"
|
||||
changelog: >
|
||||
changelog
|
||||
Initial release
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user