Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 003a8754a6 | |||
| b4275837bc | |||
| c1f7981ed7 |
@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.Jellypod.Api.Models;
|
using Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
@ -26,6 +27,8 @@ public class JellypodController : ControllerBase
|
|||||||
private readonly IRssFeedService _rssFeedService;
|
private readonly IRssFeedService _rssFeedService;
|
||||||
private readonly IPodcastStorageService _storageService;
|
private readonly IPodcastStorageService _storageService;
|
||||||
private readonly IPodcastDownloadService _downloadService;
|
private readonly IPodcastDownloadService _downloadService;
|
||||||
|
private readonly IOpmlService _opmlService;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="JellypodController"/> class.
|
/// Initializes a new instance of the <see cref="JellypodController"/> class.
|
||||||
@ -34,16 +37,22 @@ public class JellypodController : ControllerBase
|
|||||||
/// <param name="rssFeedService">RSS feed service.</param>
|
/// <param name="rssFeedService">RSS feed service.</param>
|
||||||
/// <param name="storageService">Storage service.</param>
|
/// <param name="storageService">Storage service.</param>
|
||||||
/// <param name="downloadService">Download service.</param>
|
/// <param name="downloadService">Download service.</param>
|
||||||
|
/// <param name="opmlService">OPML service.</param>
|
||||||
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
||||||
public JellypodController(
|
public JellypodController(
|
||||||
ILogger<JellypodController> logger,
|
ILogger<JellypodController> logger,
|
||||||
IRssFeedService rssFeedService,
|
IRssFeedService rssFeedService,
|
||||||
IPodcastStorageService storageService,
|
IPodcastStorageService storageService,
|
||||||
IPodcastDownloadService downloadService)
|
IPodcastDownloadService downloadService,
|
||||||
|
IOpmlService opmlService,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_rssFeedService = rssFeedService;
|
_rssFeedService = rssFeedService;
|
||||||
_storageService = storageService;
|
_storageService = storageService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
|
_opmlService = opmlService;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -362,4 +371,214 @@ public class JellypodController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new { RemovedCount = duplicatesToRemove.Count });
|
return Ok(new { RemovedCount = duplicatesToRemove.Count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates playback progress for an episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">Podcast ID.</param>
|
||||||
|
/// <param name="episodeId">Episode ID.</param>
|
||||||
|
/// <param name="request">Progress update request.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/progress")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> UpdatePlaybackProgress(
|
||||||
|
[FromRoute] Guid podcastId,
|
||||||
|
[FromRoute] Guid episodeId,
|
||||||
|
[FromBody] PlaybackProgressRequest request)
|
||||||
|
{
|
||||||
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
||||||
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
|
|
||||||
|
if (podcast == null || episode == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.PlaybackPositionTicks = request.PositionTicks;
|
||||||
|
episode.LastPlayedDate = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Mark as played if we've reached near the end (within last 5%)
|
||||||
|
if (episode.Duration.HasValue && request.PositionTicks > 0)
|
||||||
|
{
|
||||||
|
var durationTicks = episode.Duration.Value.Ticks;
|
||||||
|
var percentComplete = (double)request.PositionTicks / durationTicks;
|
||||||
|
if (percentComplete >= 0.95)
|
||||||
|
{
|
||||||
|
episode.IsPlayed = true;
|
||||||
|
episode.PlayCount++;
|
||||||
|
_logger.LogInformation("Episode marked as played: {Title} (played {Count} times)", episode.Title, episode.PlayCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks an episode as played or unplayed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">Podcast ID.</param>
|
||||||
|
/// <param name="episodeId">Episode ID.</param>
|
||||||
|
/// <param name="played">Whether the episode is played.</param>
|
||||||
|
/// <returns>No content.</returns>
|
||||||
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/played")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult> SetPlayedStatus(
|
||||||
|
[FromRoute] Guid podcastId,
|
||||||
|
[FromRoute] Guid episodeId,
|
||||||
|
[FromQuery] bool played = 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
episode.IsPlayed = played;
|
||||||
|
if (played)
|
||||||
|
{
|
||||||
|
episode.LastPlayedDate = DateTime.UtcNow;
|
||||||
|
episode.PlayCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
episode.PlaybackPositionTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
_logger.LogInformation("Episode {Title} marked as {Status}", episode.Title, played ? "played" : "unplayed");
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets playback progress for an episode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="podcastId">Podcast ID.</param>
|
||||||
|
/// <param name="episodeId">Episode ID.</param>
|
||||||
|
/// <returns>Playback progress info.</returns>
|
||||||
|
[HttpGet("podcasts/{podcastId}/episodes/{episodeId}/progress")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PlaybackProgressResponse>> GetPlaybackProgress(
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new PlaybackProgressResponse
|
||||||
|
{
|
||||||
|
PositionTicks = episode.PlaybackPositionTicks,
|
||||||
|
IsPlayed = episode.IsPlayed,
|
||||||
|
LastPlayedDate = episode.LastPlayedDate,
|
||||||
|
PlayCount = episode.PlayCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports all podcast subscriptions as OPML.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>OPML XML file.</returns>
|
||||||
|
[HttpGet("opml/export")]
|
||||||
|
[Produces("application/xml")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ExportOpml()
|
||||||
|
{
|
||||||
|
var opml = await _opmlService.ExportToOpmlAsync().ConfigureAwait(false);
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(opml);
|
||||||
|
|
||||||
|
var fileName = $"jellypod-subscriptions-{DateTime.UtcNow:yyyy-MM-dd}.opml";
|
||||||
|
return File(bytes, "application/xml", fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports podcasts from an uploaded OPML file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="file">The OPML file to import.</param>
|
||||||
|
/// <returns>Import results.</returns>
|
||||||
|
[HttpPost("opml/import")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<OpmlImportResult>> ImportOpmlFile(IFormFile file)
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("No file uploaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.Length > 5 * 1024 * 1024) // 5MB limit
|
||||||
|
{
|
||||||
|
return BadRequest("File too large. Maximum size is 5MB.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var outlines = _opmlService.ParseOpml(stream);
|
||||||
|
|
||||||
|
if (outlines.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("No podcast feeds found in OPML file");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _opmlService.ImportPodcastsAsync(outlines).ConfigureAwait(false);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (System.Xml.XmlException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Invalid OPML XML");
|
||||||
|
return BadRequest("Invalid OPML file format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports podcasts from an OPML URL.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Request containing the OPML URL.</param>
|
||||||
|
/// <returns>Import results.</returns>
|
||||||
|
[HttpPost("opml/import-url")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<OpmlImportResult>> ImportOpmlUrl([FromBody] OpmlImportRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Url))
|
||||||
|
{
|
||||||
|
return BadRequest("URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||||
|
var opmlContent = await httpClient.GetStringAsync(request.Url).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var outlines = _opmlService.ParseOpml(opmlContent);
|
||||||
|
|
||||||
|
if (outlines.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("No podcast feeds found in OPML");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _opmlService.ImportPodcastsAsync(outlines).ConfigureAwait(false);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch OPML from URL");
|
||||||
|
return BadRequest("Failed to fetch OPML from URL");
|
||||||
|
}
|
||||||
|
catch (System.Xml.XmlException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Invalid OPML XML from URL");
|
||||||
|
return BadRequest("Invalid OPML format");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportError.cs
Normal file
22
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportError.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Details about a failed OPML feed import.
|
||||||
|
/// </summary>
|
||||||
|
public class OpmlImportError
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the feed URL that failed.
|
||||||
|
/// </summary>
|
||||||
|
public string FeedUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the feed title from OPML (if available).
|
||||||
|
/// </summary>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the error message.
|
||||||
|
/// </summary>
|
||||||
|
public string Error { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
12
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportRequest.cs
Normal file
12
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportRequest.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to import podcasts from an OPML URL.
|
||||||
|
/// </summary>
|
||||||
|
public class OpmlImportRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the URL to fetch OPML from.
|
||||||
|
/// </summary>
|
||||||
|
public string Url { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
44
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportResult.cs
Normal file
44
Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportResult.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of an OPML import operation.
|
||||||
|
/// </summary>
|
||||||
|
public class OpmlImportResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of feeds found in the OPML.
|
||||||
|
/// </summary>
|
||||||
|
public int TotalFeeds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of feeds successfully imported.
|
||||||
|
/// </summary>
|
||||||
|
public int ImportedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of feeds skipped (already subscribed).
|
||||||
|
/// </summary>
|
||||||
|
public int SkippedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of feeds that failed to import.
|
||||||
|
/// </summary>
|
||||||
|
public int FailedCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of imported podcast titles.
|
||||||
|
/// </summary>
|
||||||
|
public Collection<string> ImportedPodcasts { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of skipped feed URLs (already subscribed).
|
||||||
|
/// </summary>
|
||||||
|
public Collection<string> SkippedFeeds { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of failed imports with error details.
|
||||||
|
/// </summary>
|
||||||
|
public Collection<OpmlImportError> Errors { get; } = new();
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to update playback progress.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaybackProgressRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the playback position in ticks.
|
||||||
|
/// </summary>
|
||||||
|
public long PositionTicks { get; set; }
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response containing playback progress info.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaybackProgressResponse
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the playback position in ticks.
|
||||||
|
/// </summary>
|
||||||
|
public long PositionTicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the episode has been played.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPlayed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date the episode was last played.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastPlayedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of times the episode has been played.
|
||||||
|
/// </summary>
|
||||||
|
public int PlayCount { get; set; }
|
||||||
|
}
|
||||||
@ -236,13 +236,27 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
|||||||
|
|
||||||
foreach (var episode in episodes)
|
foreach (var episode in episodes)
|
||||||
{
|
{
|
||||||
|
// Build episode name with played indicator
|
||||||
|
var episodeName = episode.IsPlayed
|
||||||
|
? $"[Played] {episode.Title}"
|
||||||
|
: episode.Title;
|
||||||
|
|
||||||
|
// Build overview with progress info if partially played
|
||||||
|
var overview = episode.Description ?? string.Empty;
|
||||||
|
if (episode.PlaybackPositionTicks > 0 && !episode.IsPlayed && episode.Duration.HasValue)
|
||||||
|
{
|
||||||
|
var progressPercent = (int)((double)episode.PlaybackPositionTicks / episode.Duration.Value.Ticks * 100);
|
||||||
|
var positionTime = TimeSpan.FromTicks(episode.PlaybackPositionTicks);
|
||||||
|
overview = $"[{progressPercent}% - {positionTime:hh\\:mm\\:ss}] {overview}";
|
||||||
|
}
|
||||||
|
|
||||||
// Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo
|
// Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo
|
||||||
// which allows us to download-on-demand and return proper local file paths
|
// which allows us to download-on-demand and return proper local file paths
|
||||||
items.Add(new ChannelItemInfo
|
items.Add(new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = episode.Id.ToString("N"),
|
Id = episode.Id.ToString("N"),
|
||||||
Name = episode.Title,
|
Name = episodeName,
|
||||||
Overview = episode.Description,
|
Overview = overview,
|
||||||
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
|
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
ContentType = ChannelMediaContentType.Podcast,
|
ContentType = ChannelMediaContentType.Podcast,
|
||||||
|
|||||||
@ -4,42 +4,64 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Jellypod</title>
|
<title>Jellypod</title>
|
||||||
<style>
|
<style>
|
||||||
.podcast-item {
|
.podcast-table {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.podcast-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75em;
|
||||||
|
border-bottom: 2px solid rgba(255,255,255,0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.podcast-table td {
|
||||||
padding: 0.75em;
|
padding: 0.75em;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.podcast-item:last-child {
|
.podcast-table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.podcast-image {
|
.podcast-table tr:hover {
|
||||||
width: 50px;
|
background: rgba(255,255,255,0.05);
|
||||||
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 {
|
.podcast-image {
|
||||||
flex: 1;
|
width: 50px !important;
|
||||||
|
height: 50px !important;
|
||||||
|
max-width: 50px !important;
|
||||||
|
max-height: 50px !important;
|
||||||
|
min-width: 50px !important;
|
||||||
|
min-height: 50px !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
background: #333 !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.col-image img {
|
||||||
|
width: 50px !important;
|
||||||
|
height: 50px !important;
|
||||||
|
max-width: 50px !important;
|
||||||
|
max-height: 50px !important;
|
||||||
}
|
}
|
||||||
.podcast-title {
|
.podcast-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 0.25em;
|
|
||||||
}
|
}
|
||||||
.podcast-meta {
|
.podcast-meta {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
margin-top: 0.25em;
|
||||||
}
|
}
|
||||||
.podcast-actions {
|
.podcast-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.col-image {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
.col-actions {
|
||||||
|
width: 100px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.add-podcast-form {
|
.add-podcast-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -157,6 +179,19 @@
|
|||||||
<div class="verticalSection">
|
<div class="verticalSection">
|
||||||
<h2 class="sectionTitle">Podcast Subscriptions</h2>
|
<h2 class="sectionTitle">Podcast Subscriptions</h2>
|
||||||
|
|
||||||
|
<!-- OPML Import/Export -->
|
||||||
|
<div class="opml-actions" style="margin-bottom: 1em; display: flex; gap: 0.5em; align-items: center;">
|
||||||
|
<button is="emby-button" type="button" id="btnExportOpml" class="raised emby-button">
|
||||||
|
<span class="material-icons" style="margin-right: 0.25em;">download</span>
|
||||||
|
<span>Export OPML</span>
|
||||||
|
</button>
|
||||||
|
<button is="emby-button" type="button" id="btnImportOpml" class="raised emby-button">
|
||||||
|
<span class="material-icons" style="margin-right: 0.25em;">upload</span>
|
||||||
|
<span>Import OPML</span>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="opmlFileInput" accept=".opml,.xml" style="display: none;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add Podcast Form -->
|
<!-- Add Podcast Form -->
|
||||||
<div class="add-podcast-form">
|
<div class="add-podcast-form">
|
||||||
<div class="inputContainer">
|
<div class="inputContainer">
|
||||||
@ -224,7 +259,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = podcasts.map(function(podcast) {
|
var rows = podcasts.map(function(podcast) {
|
||||||
// Handle both PascalCase (C#) and camelCase (JSON) property names
|
// Handle both PascalCase (C#) and camelCase (JSON) property names
|
||||||
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
|
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
|
||||||
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
|
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
|
||||||
@ -235,25 +270,36 @@
|
|||||||
|
|
||||||
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
|
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
|
||||||
|
|
||||||
return '<div class="podcast-item" data-id="' + podcastId + '">' +
|
return '<tr data-id="' + podcastId + '">' +
|
||||||
'<img class="podcast-image" src="' + podcastImage + '" alt="" onerror="this.style.display=\'none\'">' +
|
'<td class="col-image">' +
|
||||||
'<div class="podcast-info">' +
|
'<img class="podcast-image" src="' + podcastImage + '" alt="" style="width:50px;height:50px;max-width:50px;max-height:50px;object-fit:cover;" onerror="this.style.display=\'none\'">' +
|
||||||
|
'</td>' +
|
||||||
|
'<td>' +
|
||||||
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
|
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
|
||||||
'<div class="podcast-meta">' +
|
'<div class="podcast-meta">' + episodeCount + ' episodes | Updated: ' + lastUpdatedStr + '</div>' +
|
||||||
episodeCount + ' episodes | Updated: ' + lastUpdatedStr +
|
'</td>' +
|
||||||
|
'<td class="col-actions">' +
|
||||||
|
'<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>' +
|
||||||
'</div>' +
|
'</td>' +
|
||||||
'<div class="podcast-actions">' +
|
'</tr>';
|
||||||
'<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('');
|
}).join('');
|
||||||
|
|
||||||
|
var html = '<table class="podcast-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th class="col-image"></th>' +
|
||||||
|
'<th>Podcast</th>' +
|
||||||
|
'<th class="col-actions">Actions</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>';
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
console.log('Jellypod: Rendered HTML length:', html.length);
|
console.log('Jellypod: Rendered HTML length:', html.length);
|
||||||
}
|
}
|
||||||
@ -377,6 +423,102 @@
|
|||||||
addPodcast();
|
addPodcast();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export OPML
|
||||||
|
document.querySelector('#btnExportOpml').addEventListener('click', function() {
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
|
||||||
|
fetch(ApiClient.getUrl('Jellypod/opml/export'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': ApiClient.accessToken() ? ('MediaBrowser Token="' + ApiClient.accessToken() + '"') : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Export failed');
|
||||||
|
}
|
||||||
|
return response.blob();
|
||||||
|
})
|
||||||
|
.then(function(blob) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
var url = window.URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'jellypod-subscriptions-' + new Date().toISOString().split('T')[0] + '.opml';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
Dashboard.alert('Export failed: ' + err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import OPML - trigger file picker
|
||||||
|
document.querySelector('#btnImportOpml').addEventListener('click', function() {
|
||||||
|
document.querySelector('#opmlFileInput').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
document.querySelector('#opmlFileInput').addEventListener('change', function(e) {
|
||||||
|
var file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Reset input so same file can be selected again
|
||||||
|
e.target.value = '';
|
||||||
|
|
||||||
|
Dashboard.showLoadingMsg();
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
// Use fetch for multipart/form-data upload
|
||||||
|
fetch(ApiClient.getUrl('Jellypod/opml/import'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': ApiClient.accessToken() ? ('MediaBrowser Token="' + ApiClient.accessToken() + '"') : ''
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(function(response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(function(text) {
|
||||||
|
throw new Error(text || 'Import failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(function(result) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
loadPodcasts();
|
||||||
|
|
||||||
|
var message = 'Import complete!\n\n' +
|
||||||
|
'Imported: ' + result.importedCount + '\n' +
|
||||||
|
'Skipped (already subscribed): ' + result.skippedCount + '\n' +
|
||||||
|
'Failed: ' + result.failedCount;
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
message += '\n\nFailed feeds:\n';
|
||||||
|
result.errors.slice(0, 5).forEach(function(err) {
|
||||||
|
message += '- ' + (err.title || err.feedUrl) + ': ' + err.error + '\n';
|
||||||
|
});
|
||||||
|
if (result.errors.length > 5) {
|
||||||
|
message += '... and ' + (result.errors.length - 5) + ' more';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard.alert(message);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
Dashboard.hideLoadingMsg();
|
||||||
|
Dashboard.alert('Import failed: ' + err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -81,4 +81,24 @@ public class Episode
|
|||||||
/// Gets or sets the episode-specific image URL.
|
/// Gets or sets the episode-specific image URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ImageUrl { get; set; }
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the playback position in ticks.
|
||||||
|
/// </summary>
|
||||||
|
public long PlaybackPositionTicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the episode has been played/completed.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPlayed { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the date the episode was last played.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastPlayedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of times the episode has been played.
|
||||||
|
/// </summary>
|
||||||
|
public int PlayCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
42
Jellyfin.Plugin.Jellypod/Models/OpmlOutline.cs
Normal file
42
Jellyfin.Plugin.Jellypod/Models/OpmlOutline.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
namespace Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an outline element from an OPML file.
|
||||||
|
/// </summary>
|
||||||
|
public class OpmlOutline
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the outline title (text attribute).
|
||||||
|
/// </summary>
|
||||||
|
public string? Text { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the outline title (title attribute).
|
||||||
|
/// </summary>
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the RSS feed URL (xmlUrl attribute).
|
||||||
|
/// </summary>
|
||||||
|
public string? XmlUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the website URL (htmlUrl attribute).
|
||||||
|
/// </summary>
|
||||||
|
public string? HtmlUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the description.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the category.
|
||||||
|
/// </summary>
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the display title (prefers Title over Text).
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayTitle => Title ?? Text ?? "Unknown";
|
||||||
|
}
|
||||||
@ -26,8 +26,12 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
|
|||||||
serviceCollection.AddSingleton<IRssFeedService, RssFeedService>();
|
serviceCollection.AddSingleton<IRssFeedService, RssFeedService>();
|
||||||
serviceCollection.AddSingleton<IPodcastStorageService, PodcastStorageService>();
|
serviceCollection.AddSingleton<IPodcastStorageService, PodcastStorageService>();
|
||||||
serviceCollection.AddSingleton<IPodcastDownloadService, PodcastDownloadService>();
|
serviceCollection.AddSingleton<IPodcastDownloadService, PodcastDownloadService>();
|
||||||
|
serviceCollection.AddSingleton<IOpmlService, OpmlService>();
|
||||||
|
|
||||||
// Register channel
|
// Register channel
|
||||||
serviceCollection.AddSingleton<IChannel, JellypodChannel>();
|
serviceCollection.AddSingleton<IChannel, JellypodChannel>();
|
||||||
|
|
||||||
|
// Register playback reporting service
|
||||||
|
serviceCollection.AddHostedService<PlaybackReportingService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
Jellyfin.Plugin.Jellypod/Services/IOpmlService.cs
Normal file
44
Jellyfin.Plugin.Jellypod/Services/IOpmlService.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for handling OPML import and export.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOpmlService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses OPML content and extracts podcast outlines.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="opmlContent">The OPML XML content.</param>
|
||||||
|
/// <returns>List of parsed outlines.</returns>
|
||||||
|
IReadOnlyList<OpmlOutline> ParseOpml(string opmlContent);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses OPML from a stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">The input stream containing OPML XML.</param>
|
||||||
|
/// <returns>List of parsed outlines.</returns>
|
||||||
|
IReadOnlyList<OpmlOutline> ParseOpml(Stream stream);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports podcasts from parsed OPML outlines.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="outlines">The parsed OPML outlines.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Import result with statistics.</returns>
|
||||||
|
Task<OpmlImportResult> ImportPodcastsAsync(
|
||||||
|
IReadOnlyList<OpmlOutline> outlines,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports all subscribed podcasts to OPML format.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>OPML XML string.</returns>
|
||||||
|
Task<string> ExportToOpmlAsync();
|
||||||
|
}
|
||||||
263
Jellyfin.Plugin.Jellypod/Services/OpmlService.cs
Normal file
263
Jellyfin.Plugin.Jellypod/Services/OpmlService.cs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Api.Models;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for handling OPML import and export operations.
|
||||||
|
/// </summary>
|
||||||
|
public class OpmlService : IOpmlService
|
||||||
|
{
|
||||||
|
private readonly ILogger<OpmlService> _logger;
|
||||||
|
private readonly IRssFeedService _rssFeedService;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="OpmlService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
/// <param name="rssFeedService">RSS feed service.</param>
|
||||||
|
/// <param name="storageService">Storage service.</param>
|
||||||
|
public OpmlService(
|
||||||
|
ILogger<OpmlService> logger,
|
||||||
|
IRssFeedService rssFeedService,
|
||||||
|
IPodcastStorageService storageService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_rssFeedService = rssFeedService;
|
||||||
|
_storageService = storageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<OpmlOutline> ParseOpml(string opmlContent)
|
||||||
|
{
|
||||||
|
using var reader = new StringReader(opmlContent);
|
||||||
|
var doc = XDocument.Load(reader);
|
||||||
|
return ParseOpmlDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<OpmlOutline> ParseOpml(Stream stream)
|
||||||
|
{
|
||||||
|
var doc = XDocument.Load(stream);
|
||||||
|
return ParseOpmlDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OpmlOutline> ParseOpmlDocument(XDocument doc)
|
||||||
|
{
|
||||||
|
var outlines = new List<OpmlOutline>();
|
||||||
|
var body = doc.Root?.Element("body");
|
||||||
|
|
||||||
|
if (body == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("OPML document has no body element");
|
||||||
|
return outlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively find all outline elements with xmlUrl (podcast feeds)
|
||||||
|
ParseOutlines(body.Elements("outline"), outlines, null);
|
||||||
|
|
||||||
|
_logger.LogInformation("Parsed {Count} podcast feeds from OPML", outlines.Count);
|
||||||
|
return outlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseOutlines(
|
||||||
|
IEnumerable<XElement> elements,
|
||||||
|
List<OpmlOutline> outlines,
|
||||||
|
string? parentCategory)
|
||||||
|
{
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
var xmlUrl = element.Attribute("xmlUrl")?.Value;
|
||||||
|
var text = element.Attribute("text")?.Value;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(xmlUrl))
|
||||||
|
{
|
||||||
|
// This is a feed outline
|
||||||
|
outlines.Add(new OpmlOutline
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
Title = element.Attribute("title")?.Value,
|
||||||
|
XmlUrl = xmlUrl,
|
||||||
|
HtmlUrl = element.Attribute("htmlUrl")?.Value,
|
||||||
|
Description = element.Attribute("description")?.Value,
|
||||||
|
Category = parentCategory ?? element.Attribute("category")?.Value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (element.HasElements)
|
||||||
|
{
|
||||||
|
// This might be a category/folder - use text as category for children
|
||||||
|
var category = parentCategory ?? text;
|
||||||
|
ParseOutlines(element.Elements("outline"), outlines, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OpmlImportResult> ImportPodcastsAsync(
|
||||||
|
IReadOnlyList<OpmlOutline> outlines,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new OpmlImportResult { TotalFeeds = outlines.Count };
|
||||||
|
|
||||||
|
// Get existing feeds to check for duplicates
|
||||||
|
var existingPodcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
var existingUrls = existingPodcasts
|
||||||
|
.Select(p => p.FeedUrl)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var outline in outlines)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(outline.XmlUrl))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
if (existingUrls.Contains(outline.XmlUrl))
|
||||||
|
{
|
||||||
|
result.SkippedCount++;
|
||||||
|
result.SkippedFeeds.Add(outline.XmlUrl);
|
||||||
|
_logger.LogDebug("Skipping already subscribed feed: {Url}", outline.XmlUrl);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var podcast = await _rssFeedService.FetchPodcastAsync(
|
||||||
|
outline.XmlUrl,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (podcast == null)
|
||||||
|
{
|
||||||
|
result.FailedCount++;
|
||||||
|
result.Errors.Add(new OpmlImportError
|
||||||
|
{
|
||||||
|
FeedUrl = outline.XmlUrl,
|
||||||
|
Title = outline.DisplayTitle,
|
||||||
|
Error = "Failed to fetch or parse RSS feed"
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve category from OPML if the feed doesn't have one
|
||||||
|
if (string.IsNullOrEmpty(podcast.Category) && !string.IsNullOrEmpty(outline.Category))
|
||||||
|
{
|
||||||
|
podcast.Category = outline.Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.AddPodcastAsync(podcast).ConfigureAwait(false);
|
||||||
|
|
||||||
|
result.ImportedCount++;
|
||||||
|
result.ImportedPodcasts.Add(podcast.Title);
|
||||||
|
existingUrls.Add(outline.XmlUrl); // Prevent duplicate adds within same import
|
||||||
|
|
||||||
|
_logger.LogInformation("Imported podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.FailedCount++;
|
||||||
|
result.Errors.Add(new OpmlImportError
|
||||||
|
{
|
||||||
|
FeedUrl = outline.XmlUrl,
|
||||||
|
Title = outline.DisplayTitle,
|
||||||
|
Error = ex.Message
|
||||||
|
});
|
||||||
|
_logger.LogWarning(ex, "Failed to import feed: {Url}", outline.XmlUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"OPML import complete: {Imported} imported, {Skipped} skipped, {Failed} failed out of {Total}",
|
||||||
|
result.ImportedCount,
|
||||||
|
result.SkippedCount,
|
||||||
|
result.FailedCount,
|
||||||
|
result.TotalFeeds);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string> ExportToOpmlAsync()
|
||||||
|
{
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var doc = new XDocument(
|
||||||
|
new XDeclaration("1.0", "utf-8", null),
|
||||||
|
new XElement(
|
||||||
|
"opml",
|
||||||
|
new XAttribute("version", "2.0"),
|
||||||
|
new XElement(
|
||||||
|
"head",
|
||||||
|
new XElement("title", "Jellypod Podcast Subscriptions"),
|
||||||
|
new XElement("dateCreated", DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture)),
|
||||||
|
new XElement("docs", "http://opml.org/spec2.opml")),
|
||||||
|
new XElement(
|
||||||
|
"body",
|
||||||
|
podcasts.Select(p => CreateOutlineElement(p)))));
|
||||||
|
|
||||||
|
var settings = new XmlWriterSettings
|
||||||
|
{
|
||||||
|
Indent = true,
|
||||||
|
Encoding = new UTF8Encoding(false),
|
||||||
|
OmitXmlDeclaration = false
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stringWriter = new StringWriter();
|
||||||
|
using (var xmlWriter = XmlWriter.Create(stringWriter, settings))
|
||||||
|
{
|
||||||
|
doc.Save(xmlWriter);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Exported {Count} podcasts to OPML", podcasts.Count);
|
||||||
|
return stringWriter.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static XElement CreateOutlineElement(Podcast podcast)
|
||||||
|
{
|
||||||
|
var element = new XElement(
|
||||||
|
"outline",
|
||||||
|
new XAttribute("type", "rss"),
|
||||||
|
new XAttribute("text", podcast.Title),
|
||||||
|
new XAttribute("title", podcast.Title),
|
||||||
|
new XAttribute("xmlUrl", podcast.FeedUrl));
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(podcast.Description))
|
||||||
|
{
|
||||||
|
element.Add(new XAttribute("description", TruncateDescription(podcast.Description)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(podcast.Category))
|
||||||
|
{
|
||||||
|
element.Add(new XAttribute("category", podcast.Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateDescription(string description, int maxLength = 200)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(description) || description.Length <= maxLength)
|
||||||
|
{
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Concat(description.AsSpan(0, maxLength), "...");
|
||||||
|
}
|
||||||
|
}
|
||||||
229
Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs
Normal file
229
Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Plugin.Jellypod.Models;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that listens to Jellyfin playback events and tracks podcast progress.
|
||||||
|
/// </summary>
|
||||||
|
public class PlaybackReportingService : IHostedService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IPodcastStorageService _storageService;
|
||||||
|
private readonly ILogger<PlaybackReportingService> _logger;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PlaybackReportingService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionManager">Session manager.</param>
|
||||||
|
/// <param name="storageService">Podcast storage service.</param>
|
||||||
|
/// <param name="logger">Logger instance.</param>
|
||||||
|
public PlaybackReportingService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IPodcastStorageService storageService,
|
||||||
|
ILogger<PlaybackReportingService> logger)
|
||||||
|
{
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
_storageService = storageService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStart += OnPlaybackStart;
|
||||||
|
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||||
|
_sessionManager.PlaybackProgress += OnPlaybackProgress;
|
||||||
|
|
||||||
|
_logger.LogInformation("Jellypod playback reporting service started");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStart -= OnPlaybackStart;
|
||||||
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||||
|
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
||||||
|
|
||||||
|
_logger.LogInformation("Jellypod playback reporting service stopped");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases unmanaged and optionally managed resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing">True to release both managed and unmanaged resources.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_sessionManager.PlaybackStart -= OnPlaybackStart;
|
||||||
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||||
|
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackStart(object? sender, PlaybackProgressEventArgs e)
|
||||||
|
{
|
||||||
|
_ = HandlePlaybackEventAsync(e, "start");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
|
||||||
|
{
|
||||||
|
_ = HandlePlaybackEventAsync(e, "progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||||
|
{
|
||||||
|
_ = HandlePlaybackStoppedAsync(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePlaybackEventAsync(PlaybackProgressEventArgs e, string eventType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var item = e.Item;
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a channel item (podcast episode)
|
||||||
|
var channelId = item.ChannelId;
|
||||||
|
if (channelId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the episode by its ID
|
||||||
|
var episodeIdStr = item.Id.ToString("N");
|
||||||
|
var episode = await FindEpisodeByIdAsync(episodeIdStr).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (episode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Playback {EventType} for podcast episode: {Title}, Position: {Position}",
|
||||||
|
eventType,
|
||||||
|
episode.Episode.Title,
|
||||||
|
e.PlaybackPositionTicks);
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
episode.Episode.PlaybackPositionTicks = e.PlaybackPositionTicks ?? 0;
|
||||||
|
episode.Episode.LastPlayedDate = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(episode.Podcast).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling playback {EventType} event", eventType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePlaybackStoppedAsync(PlaybackStopEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var item = e.Item;
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a channel item (podcast episode)
|
||||||
|
var channelId = item.ChannelId;
|
||||||
|
if (channelId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the episode by its ID
|
||||||
|
var episodeIdStr = item.Id.ToString("N");
|
||||||
|
var episode = await FindEpisodeByIdAsync(episodeIdStr).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (episode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var positionTicks = e.PlaybackPositionTicks ?? 0;
|
||||||
|
episode.Episode.PlaybackPositionTicks = positionTicks;
|
||||||
|
episode.Episode.LastPlayedDate = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Check if episode is complete (95% or more)
|
||||||
|
if (episode.Episode.Duration.HasValue && positionTicks > 0)
|
||||||
|
{
|
||||||
|
var durationTicks = episode.Episode.Duration.Value.Ticks;
|
||||||
|
var percentComplete = (double)positionTicks / durationTicks;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Playback stopped for {Title} at {Percent:P1} complete",
|
||||||
|
episode.Episode.Title,
|
||||||
|
percentComplete);
|
||||||
|
|
||||||
|
if (percentComplete >= 0.95 && !episode.Episode.IsPlayed)
|
||||||
|
{
|
||||||
|
episode.Episode.IsPlayed = true;
|
||||||
|
episode.Episode.PlayCount++;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Episode marked as played: {Title} (played {Count} times)",
|
||||||
|
episode.Episode.Title,
|
||||||
|
episode.Episode.PlayCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storageService.UpdatePodcastAsync(episode.Podcast).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error handling playback stopped event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EpisodeWithPodcast?> FindEpisodeByIdAsync(string episodeId)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(episodeId, out var episodeGuid))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
||||||
|
foreach (var podcast in podcasts)
|
||||||
|
{
|
||||||
|
var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeGuid);
|
||||||
|
if (episode != null)
|
||||||
|
{
|
||||||
|
return new EpisodeWithPodcast(podcast, episode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EpisodeWithPodcast(Podcast Podcast, Episode Episode);
|
||||||
|
}
|
||||||
@ -8,6 +8,14 @@ A Jellyfin plugin that adds podcast support to your media server.
|
|||||||
- Automatic episode downloads
|
- Automatic episode downloads
|
||||||
- Integration with Jellyfin's library system
|
- Integration with Jellyfin's library system
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Podcast Library
|
||||||
|

|
||||||
|
|
||||||
|
### Plugin Settings
|
||||||
|

|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|||||||
BIN
images/library.png
Normal file
BIN
images/library.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
BIN
images/settings.png
Normal file
BIN
images/settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Loading…
x
Reference in New Issue
Block a user