diff --git a/Jellyfin.Api/Controllers/ScheduledTasksController.cs b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
new file mode 100644
index 000000000..bb07af397
--- /dev/null
+++ b/Jellyfin.Api/Controllers/ScheduledTasksController.cs
@@ -0,0 +1,207 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers
+{
+ ///
+ /// Scheduled Tasks Controller.
+ ///
+ public class ScheduledTasksController : BaseJellyfinApiController
+ {
+ private readonly ITaskManager _taskManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Instance of the interface.
+ public ScheduledTasksController(ITaskManager taskManager)
+ {
+ _taskManager = taskManager;
+ }
+
+ ///
+ /// Get tasks.
+ ///
+ /// Optional filter tasks that are hidden, or not.
+ /// Optional filter tasks that are enabled, or not.
+ /// Task list.
+ [HttpGet]
+ [ProducesResponseType(typeof(TaskInfo[]), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
+ public IActionResult GetTasks(
+ [FromQuery] bool? isHidden = false,
+ [FromQuery] bool? isEnabled = false)
+ {
+ try
+ {
+ IEnumerable tasks = _taskManager.ScheduledTasks.OrderBy(o => o.Name);
+
+ if (isHidden.HasValue)
+ {
+ var hiddenValue = isHidden.Value;
+ tasks = tasks.Where(o =>
+ {
+ var itemIsHidden = false;
+ if (o.ScheduledTask is IConfigurableScheduledTask configurableScheduledTask)
+ {
+ itemIsHidden = configurableScheduledTask.IsHidden;
+ }
+
+ return itemIsHidden == hiddenValue;
+ });
+ }
+
+ if (isEnabled.HasValue)
+ {
+ var enabledValue = isEnabled.Value;
+ tasks = tasks.Where(o =>
+ {
+ var itemIsEnabled = false;
+ if (o.ScheduledTask is IConfigurableScheduledTask configurableScheduledTask)
+ {
+ itemIsEnabled = configurableScheduledTask.IsEnabled;
+ }
+
+ return itemIsEnabled == enabledValue;
+ });
+ }
+
+ var taskInfos = tasks.Select(ScheduledTaskHelpers.GetTaskInfo);
+
+ // TODO ToOptimizedResult
+ return Ok(taskInfos);
+ }
+ catch (Exception e)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, e.Message);
+ }
+ }
+
+ ///
+ /// Get task by id.
+ ///
+ /// Task Id.
+ /// Task Info.
+ [HttpGet("{TaskID}")]
+ [ProducesResponseType(typeof(TaskInfo), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
+ public IActionResult GetTask([FromRoute] string taskId)
+ {
+ try
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(i =>
+ string.Equals(i.Id, taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ var result = ScheduledTaskHelpers.GetTaskInfo(task);
+ return Ok(result);
+ }
+ catch (Exception e)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, e.Message);
+ }
+ }
+
+ ///
+ /// Start specified task.
+ ///
+ /// Task Id.
+ /// Status.
+ [HttpPost("Running/{TaskID}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
+ public IActionResult StartTask([FromRoute] string taskId)
+ {
+ try
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ _taskManager.Execute(task, new TaskOptions());
+ return Ok();
+ }
+ catch (Exception e)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, e.Message);
+ }
+ }
+
+ ///
+ /// Stop specified task.
+ ///
+ /// Task Id.
+ /// Status.
+ [HttpDelete("Running/{TaskID}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
+ public IActionResult StopTask([FromRoute] string taskId)
+ {
+ try
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ _taskManager.Cancel(task);
+ return Ok();
+ }
+ catch (Exception e)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, e.Message);
+ }
+ }
+
+ ///
+ /// Update specified task triggers.
+ ///
+ /// Task Id.
+ /// Triggers.
+ /// Status.
+ [HttpPost("{TaskID}/Triggers")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
+ public IActionResult UpdateTask([FromRoute] string taskId, [FromBody] TaskTriggerInfo[] triggerInfos)
+ {
+ try
+ {
+ var task = _taskManager.ScheduledTasks.FirstOrDefault(o =>
+ o.Id.Equals(taskId, StringComparison.OrdinalIgnoreCase));
+ if (task == null)
+ {
+ return NotFound();
+ }
+
+ task.Triggers = triggerInfos;
+ return Ok();
+ }
+ catch (Exception e)
+ {
+ return StatusCode(StatusCodes.Status500InternalServerError, e.Message);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Converters/LongToStringConverter.cs b/Jellyfin.Server/Converters/LongToStringConverter.cs
new file mode 100644
index 000000000..ad66b7b0c
--- /dev/null
+++ b/Jellyfin.Server/Converters/LongToStringConverter.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Buffers;
+using System.Buffers.Text;
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Server.Converters
+{
+ ///
+ /// Long to String JSON converter.
+ /// Javascript does not support 64-bit integers.
+ ///
+ public class LongToStringConverter : JsonConverter
+ {
+ ///
+ /// Read JSON string as Long.
+ ///
+ /// .
+ /// Type.
+ /// Options.
+ /// Parsed value.
+ public override long Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ // try to parse number directly from bytes
+ ReadOnlySpan span = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan;
+ if (Utf8Parser.TryParse(span, out long number, out int bytesConsumed) && span.Length == bytesConsumed)
+ {
+ return number;
+ }
+
+ // try to parse from a string if the above failed, this covers cases with other escaped/UTF characters
+ if (long.TryParse(reader.GetString(), out number))
+ {
+ return number;
+ }
+ }
+
+ // fallback to default handling
+ return reader.GetInt64();
+ }
+
+ ///
+ /// Write long to JSON string.
+ ///
+ /// .
+ /// Value to write.
+ /// Options.
+ public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString(NumberFormatInfo.InvariantInfo));
+ }
+ }
+}
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 71ef9a69a..afd42ac5a 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -4,6 +4,7 @@ using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
+using Jellyfin.Server.Converters;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
@@ -75,6 +76,7 @@ namespace Jellyfin.Server.Extensions
{
// Setting the naming policy to null leaves the property names as-is when serializing objects to JSON.
options.JsonSerializerOptions.PropertyNamingPolicy = null;
+ options.JsonSerializerOptions.Converters.Add(new LongToStringConverter());
})
.AddControllersAsServices();
}