Add YouTube video management functionality

- Implemented YouTubeUrlParser to extract video IDs from various YouTube URL formats.
- Added a new SoftwareVideos page for managing videos associated with software.
- Introduced methods in SoftwareService for creating, updating, and deleting video entries.
- Enhanced the Software admin page with a button to navigate to the video management section.
- Updated localization files to include new strings related to video management in multiple languages.
This commit is contained in:
2026-05-07 11:40:07 +01:00
parent 782239ac2b
commit b0972a21c4
19 changed files with 1269 additions and 13 deletions

View File

@@ -0,0 +1,85 @@
// <auto-generated/>
#pragma warning disable CS0618
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using System.Collections.Generic;
using System.IO;
using System;
namespace Marechai.ApiClient.Models
{
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class CreateSoftwareVideoRequest : IAdditionalDataHolder, IParsable
#pragma warning restore CS1591
{
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The provider property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? Provider { get; set; }
#nullable restore
#else
public string Provider { get; set; }
#endif
/// <summary>The title property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? Title { get; set; }
#nullable restore
#else
public string Title { get; set; }
#endif
/// <summary>The video_id property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? VideoId { get; set; }
#nullable restore
#else
public string VideoId { get; set; }
#endif
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest"/> and sets the default values.
/// </summary>
public CreateSoftwareVideoRequest()
{
AdditionalData = new Dictionary<string, object>();
}
/// <summary>
/// Creates a new instance of the appropriate class based on discriminator value
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest"/></returns>
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
public static global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest CreateFromDiscriminatorValue(IParseNode parseNode)
{
if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode));
return new global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest();
}
/// <summary>
/// The deserialization information for the current model
/// </summary>
/// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
{
return new Dictionary<string, Action<IParseNode>>
{
{ "provider", n => { Provider = n.GetStringValue(); } },
{ "title", n => { Title = n.GetStringValue(); } },
{ "video_id", n => { VideoId = n.GetStringValue(); } },
};
}
/// <summary>
/// Serializes information the current object
/// </summary>
/// <param name="writer">Serialization writer to use to serialize this model</param>
public virtual void Serialize(ISerializationWriter writer)
{
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteStringValue("provider", Provider);
writer.WriteStringValue("title", Title);
writer.WriteStringValue("video_id", VideoId);
writer.WriteAdditionalData(AdditionalData);
}
}
}
#pragma warning restore CS0618

View File

@@ -0,0 +1,65 @@
// <auto-generated/>
#pragma warning disable CS0618
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions.Serialization;
using System.Collections.Generic;
using System.IO;
using System;
namespace Marechai.ApiClient.Models
{
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
#pragma warning disable CS1591
public partial class UpdateSoftwareVideoRequest : IAdditionalDataHolder, IParsable
#pragma warning restore CS1591
{
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The title property</summary>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public string? Title { get; set; }
#nullable restore
#else
public string Title { get; set; }
#endif
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest"/> and sets the default values.
/// </summary>
public UpdateSoftwareVideoRequest()
{
AdditionalData = new Dictionary<string, object>();
}
/// <summary>
/// Creates a new instance of the appropriate class based on discriminator value
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest"/></returns>
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
public static global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest CreateFromDiscriminatorValue(IParseNode parseNode)
{
if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode));
return new global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest();
}
/// <summary>
/// The deserialization information for the current model
/// </summary>
/// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
{
return new Dictionary<string, Action<IParseNode>>
{
{ "title", n => { Title = n.GetStringValue(); } },
};
}
/// <summary>
/// Serializes information the current object
/// </summary>
/// <param name="writer">Serialization writer to use to serialize this model</param>
public virtual void Serialize(ISerializationWriter writer)
{
if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer));
writer.WriteStringValue("title", Title);
writer.WriteAdditionalData(AdditionalData);
}
}
}
#pragma warning restore CS0618

View File

@@ -60,14 +60,16 @@ namespace Marechai.ApiClient.Software.Item.Videos
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 403 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 409 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<global::Marechai.ApiClient.Models.SoftwareVideoDto?> PostAsync(global::Marechai.ApiClient.Models.SoftwareVideoDto body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<global::Marechai.ApiClient.Models.SoftwareVideoDto?> PostAsync(global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<global::Marechai.ApiClient.Models.SoftwareVideoDto> PostAsync(global::Marechai.ApiClient.Models.SoftwareVideoDto body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
public async Task<global::Marechai.ApiClient.Models.SoftwareVideoDto> PostAsync(global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
@@ -76,6 +78,8 @@ namespace Marechai.ApiClient.Software.Item.Videos
{
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "403", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "409", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
return await RequestAdapter.SendAsync<global::Marechai.ApiClient.Models.SoftwareVideoDto>(requestInfo, global::Marechai.ApiClient.Models.SoftwareVideoDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
@@ -101,11 +105,11 @@ namespace Marechai.ApiClient.Software.Item.Videos
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.SoftwareVideoDto body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.SoftwareVideoDto body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.CreateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));

View File

@@ -36,6 +36,7 @@ namespace Marechai.ApiClient.Software.Videos.Item
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 403 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
@@ -50,6 +51,7 @@ namespace Marechai.ApiClient.Software.Videos.Item
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "403", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
@@ -74,6 +76,33 @@ namespace Marechai.ApiClient.Software.Videos.Item
};
return await RequestAdapter.SendAsync<global::Marechai.ApiClient.Models.SoftwareVideoDto>(requestInfo, global::Marechai.ApiClient.Models.SoftwareVideoDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <param name="body">The request body</param>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 403 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task PutAsync(global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task PutAsync(global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = ToPutRequestInformation(body, requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "403", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
{ "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false);
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
@@ -106,6 +135,25 @@ namespace Marechai.ApiClient.Software.Videos.Item
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
return requestInfo;
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="body">The request body</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToPutRequestInformation(global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToPutRequestInformation(global::Marechai.ApiClient.Models.UpdateSoftwareVideoRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = new RequestInformation(Method.PUT, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/json, text/plain;q=0.9");
requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body);
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
@@ -131,6 +179,14 @@ namespace Marechai.ApiClient.Software.Videos.Item
public partial class VideosItemRequestBuilderGetRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")]
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class VideosItemRequestBuilderPutRequestConfiguration : RequestConfiguration<DefaultQueryParameters>
{
}
}
}
#pragma warning restore CS0618

View File

@@ -1,5 +1,5 @@
{
"descriptionHash": "9CD33AD0F35A1022CC152B32FF35423674776116946B4399403175D7110CDDD843BF49C2C55C3C8F5E7C2704DB6C7ED36E9EF850622E1F9C5507CBB2B71EAE2D",
"descriptionHash": "1D3DD10AC7F51E85403ABA2CCE8E12CF73B2B0FF44AE722917D1F577BD0CDA0E62E9E38E057F8CC57899B8390B95EA034BB3B25025C7EACA39C7A0BBAC24F1E6",
"descriptionLocation": "../../../../../tmp/openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",

View File

@@ -0,0 +1,46 @@
/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class CreateSoftwareVideoRequest
{
[Required]
[StringLength(32)]
[JsonPropertyName("provider")]
public string Provider { get; set; } = null!;
[Required]
[StringLength(64)]
[JsonPropertyName("video_id")]
public string VideoId { get; set; } = null!;
[StringLength(512)]
[JsonPropertyName("title")]
public string? Title { get; set; }
}

View File

@@ -0,0 +1,36 @@
/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Marechai.Data.Dtos;
public class UpdateSoftwareVideoRequest
{
[StringLength(512)]
[JsonPropertyName("title")]
public string? Title { get; set; }
}

View File

@@ -87,40 +87,90 @@ public class SoftwareVideosController(MarechaiContext context) : ControllerBase
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SoftwareVideoDto>> CreateAsync(ulong softwareId, [FromBody] SoftwareVideoDto dto)
public async Task<ActionResult<SoftwareVideoDto>> CreateAsync(ulong softwareId,
[FromBody] CreateSoftwareVideoRequest request)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId))
return BadRequest("Provider and Video ID are required.");
string provider = request.Provider.Trim();
string videoId = request.VideoId.Trim();
string title = string.IsNullOrWhiteSpace(request.Title) ? null : request.Title.Trim();
bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId);
if(!softwareExists) return NotFound();
bool exists = await context.SoftwareVideos.AnyAsync(v => v.SoftwareId == softwareId &&
v.Provider == dto.Provider &&
v.VideoId == dto.VideoId);
v.Provider == provider &&
v.VideoId == videoId);
if(exists) return Conflict("This video is already linked to this software.");
var model = new SoftwareVideo
{
SoftwareId = softwareId,
Provider = dto.Provider,
VideoId = dto.VideoId,
Title = dto.Title
Provider = provider,
VideoId = videoId,
Title = title
};
context.SoftwareVideos.Add(model);
await context.SaveChangesAsync();
dto.Id = model.Id;
dto.SoftwareId = softwareId;
string softwareName = await context.Softwares.Where(s => s.Id == softwareId)
.Select(s => s.Name)
.FirstOrDefaultAsync();
var dto = new SoftwareVideoDto
{
Id = model.Id,
SoftwareId = softwareId,
SoftwareName = softwareName,
Provider = provider,
VideoId = videoId,
Title = title
};
return CreatedAtAction(nameof(GetAsync), new { id = model.Id }, dto);
}
[HttpPut("{id:long}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateAsync(long id, [FromBody] UpdateSoftwareVideoRequest request)
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(userId is null) return Unauthorized();
SoftwareVideo video = await context.SoftwareVideos.FindAsync(id);
if(video is null) return NotFound();
video.Title = string.IsNullOrWhiteSpace(request.Title) ? null : request.Title.Trim();
await context.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:long}")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteAsync(long id)
{

View File

@@ -0,0 +1,72 @@
/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System;
using System.Text.RegularExpressions;
namespace Marechai.Helpers;
public static partial class YouTubeUrlParser
{
[GeneratedRegex(@"^(?:https?://)?(?:www\.|m\.|music\.)?(?:youtube\.com|youtube-nocookie\.com)/(?:watch\?(?:[^&\s]*&)*v=|embed/|v/|shorts/|live/)([A-Za-z0-9_-]{11})",
RegexOptions.IgnoreCase)]
private static partial Regex YouTubeFullUrlRegex();
[GeneratedRegex(@"^(?:https?://)?youtu\.be/([A-Za-z0-9_-]{11})", RegexOptions.IgnoreCase)]
private static partial Regex YouTubeShortUrlRegex();
[GeneratedRegex(@"^[A-Za-z0-9_-]{11}$")]
private static partial Regex BareIdRegex();
/// <summary>
/// Tries to extract a YouTube video ID from a URL or a bare 11-character ID. Accepts youtube.com/watch?v=,
/// youtu.be/, youtube.com/embed/, youtube.com/shorts/, youtube.com/live/, and youtube-nocookie.com URLs.
/// </summary>
public static bool TryExtract(string input, out string videoId)
{
videoId = null;
if(string.IsNullOrWhiteSpace(input)) return false;
string trimmed = input.Trim();
Match match = YouTubeFullUrlRegex().Match(trimmed);
if(!match.Success) match = YouTubeShortUrlRegex().Match(trimmed);
if(match.Success)
{
videoId = match.Groups[1].Value;
return true;
}
if(!BareIdRegex().IsMatch(trimmed)) return false;
videoId = trimmed;
return true;
}
}

View File

@@ -74,6 +74,8 @@
<MudTooltip Text="@L["Promo Art"]"><MudIconButton Icon="@Icons.Material.Filled.PhotoLibrary" Size="Size.Small" OnClick="() => NavigateToPromoArt(context.Item)"/></MudTooltip>
<MudTooltip Text="@L["Videos"]"><MudIconButton Icon="@Icons.Material.Filled.VideoLibrary" Size="Size.Small" OnClick="() => NavigateToVideos(context.Item)"/></MudTooltip>
<MudTooltip Text="@L["Merge"]"><MudIconButton Icon="@Icons.Material.Filled.MergeType" Size="Size.Small" Color="Color.Warning" OnClick="() => OpenMergeDialog(context.Item)"/></MudTooltip>
<MudTooltip Text="@L["Delete"]"><MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="() => ConfirmDelete(context.Item)"/></MudTooltip>

View File

@@ -64,6 +64,9 @@ public partial class Software
void NavigateToPromoArt(SoftwareDto software) =>
NavigationManager.NavigateTo($"/admin/software/{software.Id}/promo-art");
void NavigateToVideos(SoftwareDto software) =>
NavigationManager.NavigateTo($"/admin/software/{software.Id}/videos");
async Task OpenDescriptionsDialog(SoftwareDto software)
{
DialogParameters<SoftwareDescriptionDialog> parameters = new()

View File

@@ -0,0 +1,164 @@
@page "/admin/software/{SoftwareId:int}/videos"
@attribute [Authorize(Roles = "Admin, UberAdmin")]
@using Marechai.ApiClient.Models
@using Marechai.Services
@inject SoftwareService SoftwareService
@inject IStringLocalizer<SoftwareService> L
@inject IDialogService DialogService
@inject NavigationManager NavigationManager
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="@(() => NavigationManager.NavigateTo("/admin/software"))" Class="mb-4">@L["Back to Software"]</MudButton>
<MudPaper Class="pa-6 mb-6" Elevation="0" Style="background: linear-gradient(135deg, #b71c1c 0%, #e53935 100%); border-radius: 16px;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudIcon Icon="@Icons.Material.Filled.VideoLibrary" Size="Size.Large" Style="color: white;"/>
<MudText Typo="Typo.h4" Style="color: white;">@L["Video Management"]</MudText>
</MudStack>
<MudText Typo="Typo.subtitle1" Style="color: rgba(255,255,255,0.85);" Class="mt-2">@(_softwareName ?? L["Manage software videos."])</MudText>
</MudPaper>
@if(!string.IsNullOrWhiteSpace(_errorMessage))
{
<MudAlert Severity="Severity.Error" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="() => _errorMessage = null">@_errorMessage</MudAlert>
}
@if(!string.IsNullOrWhiteSpace(_successMessage))
{
<MudAlert Severity="Severity.Success" Class="mb-4" ShowCloseIcon="true" CloseIconClicked="() => _successMessage = null">@_successMessage</MudAlert>
}
@if(_isLoading)
{
<div class="d-flex justify-center pa-8">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large"/>
</div>
return;
}
@* ── Add Video Section ── *@
<MudPaper Elevation="2" Class="pa-4 mb-6 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">@L["Link Video"]</MudText>
<MudStack Spacing="3">
<MudTextField @bind-Value="_urlInput"
@bind-Value:after="OnUrlInputChanged"
Label="@L["YouTube URL or Video ID"]"
Variant="Variant.Outlined"
Placeholder="https://www.youtube.com/watch?v=..."
Immediate="true"
DebounceInterval="200"/>
@if(!string.IsNullOrWhiteSpace(_detectedVideoId))
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success"/>
<MudText Typo="Typo.body2">@L["Detected Video ID"]:</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_detectedVideoId</MudChip>
</MudStack>
}
<MudTextField @bind-Value="_title"
Label="@L["Video Title"]"
Variant="Variant.Outlined"
MaxLength="512"/>
<MudExpansionPanels Elevation="0">
<MudExpansionPanel Text="@L["Advanced"]">
<MudStack Spacing="3">
<MudTextField @bind-Value="_provider"
Label="@L["Provider"]"
Variant="Variant.Outlined"
HelperText="@L["Provider name (e.g. YouTube)."]"
MaxLength="32"/>
<MudTextField @bind-Value="_manualVideoId"
Label="@L["Video ID"]"
Variant="Variant.Outlined"
HelperText="@L["Use this when the URL above cannot be parsed (non-YouTube providers)."]"
MaxLength="64"/>
</MudStack>
</MudExpansionPanel>
</MudExpansionPanels>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Link"
OnClick="LinkVideoAsync"
Disabled="_isSaving || !CanLink">
@if(_isSaving)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2"/>
@L["Saving..."]
}
else
{
@L["Link Video"]
}
</MudButton>
</MudStack>
</MudPaper>
@* ── Videos Grid ── *@
<MudPaper Elevation="2" Class="rounded-lg">
<MudToolBar Dense="true">
<MudText Typo="Typo.h6">@L["Videos"]</MudText>
@if(_videos is not null)
{
<MudChip T="string" Size="Size.Small" Color="Color.Secondary" Class="ml-2">@_videos.Count</MudChip>
}
<MudSpacer/>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadDataAsync"/>
</MudToolBar>
@if(_videos is null || _videos.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="ma-4">@L["No videos found for this software."]</MudAlert>
}
else
{
<MudGrid Class="pa-4">
@foreach(SoftwareVideoDto video in _videos)
{
<MudItem xs="12" sm="6" md="4">
<MudCard Elevation="1" Class="rounded-lg">
<MudCardContent Class="pa-2">
@if(string.Equals(video.Provider, "YouTube", StringComparison.OrdinalIgnoreCase))
{
<div style="position: relative; width: 100%; padding-bottom: 56.25%; border-radius: 8px; overflow: hidden;">
<iframe src="@($"https://www.youtube-nocookie.com/embed/{video.VideoId}")"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="true">
@video.Provider: @video.VideoId
</MudAlert>
}
</MudCardContent>
<MudCardContent Class="pa-2">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@video.Provider</MudChip>
<MudText Typo="Typo.caption" Class="text-truncate" Style="font-family: monospace;">@video.VideoId</MudText>
</MudStack>
<MudTextField T="string"
Value="@video.Title"
ValueChanged="@(t => OnTitleChangedAsync(video, t))"
Label="@L["Title"]"
Variant="Variant.Outlined"
DebounceInterval="500"
MaxLength="512"
Class="mt-2"/>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Text" Color="Color.Error" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="() => ConfirmDeleteAsync(video)">@L["Delete"]</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
}
</MudPaper>

View File

@@ -0,0 +1,168 @@
/******************************************************************************
// MARECHAI: Master repository of computing history artifacts information
// ----------------------------------------------------------------------------
//
// Author(s) : Natalia Portillo <claunia@claunia.com>
//
// --[ License ] --------------------------------------------------------------
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// ----------------------------------------------------------------------------
// Copyright © 2003-2026 Natalia Portillo
*******************************************************************************/
using System.Collections.Generic;
using System.Threading.Tasks;
using Marechai.ApiClient.Models;
using Marechai.Helpers;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace Marechai.Pages.Admin;
public partial class SoftwareVideos
{
string _detectedVideoId;
string _errorMessage;
bool _isLoading = true;
bool _isSaving;
string _manualVideoId;
string _provider = "YouTube";
string _softwareName;
string _successMessage;
string _title;
string _urlInput;
List<SoftwareVideoDto> _videos;
[Parameter] public int SoftwareId { get; set; }
bool CanLink => !string.IsNullOrWhiteSpace(EffectiveProvider) && !string.IsNullOrWhiteSpace(EffectiveVideoId);
string EffectiveProvider => string.IsNullOrWhiteSpace(_manualVideoId)
? "YouTube"
: (_provider ?? string.Empty).Trim();
string EffectiveVideoId => !string.IsNullOrWhiteSpace(_manualVideoId)
? _manualVideoId.Trim()
: _detectedVideoId;
protected override async Task OnInitializedAsync()
{
SoftwareDto software = await SoftwareService.GetSoftwareByIdAsync(SoftwareId);
_softwareName = software?.Name;
await LoadDataAsync();
}
async Task LoadDataAsync()
{
_isLoading = true;
_videos = await SoftwareService.GetVideosBySoftwareAsync(SoftwareId);
_isLoading = false;
}
void OnUrlInputChanged()
{
_detectedVideoId = YouTubeUrlParser.TryExtract(_urlInput, out string id) ? id : null;
}
async Task LinkVideoAsync()
{
_errorMessage = null;
_successMessage = null;
if(!CanLink) return;
_isSaving = true;
try
{
(SoftwareVideoDto dto, string error) = await SoftwareService.CreateVideoAsync(SoftwareId,
EffectiveProvider, EffectiveVideoId, string.IsNullOrWhiteSpace(_title) ? null : _title.Trim());
if(dto is not null)
{
_successMessage = L["Video linked successfully."];
_urlInput = null;
_detectedVideoId = null;
_manualVideoId = null;
_title = null;
_provider = "YouTube";
await LoadDataAsync();
}
else
{
_errorMessage = string.IsNullOrWhiteSpace(error) ? L["Failed to link video."] : error;
}
}
finally
{
_isSaving = false;
}
}
async Task OnTitleChangedAsync(SoftwareVideoDto video, string newTitle)
{
if(video.Title == newTitle) return;
(bool succeeded, string error) =
await SoftwareService.UpdateVideoTitleAsync(video.Id!.Value, newTitle);
if(succeeded)
{
video.Title = newTitle;
_successMessage = L["Video updated successfully."];
}
else
{
_errorMessage = string.IsNullOrWhiteSpace(error) ? L["Failed to update video."] : error;
}
}
async Task ConfirmDeleteAsync(SoftwareVideoDto video)
{
DialogParameters<DeleteConfirmDialog> parameters = new()
{
{
x => x.ContentText,
L["Are you sure you want to delete this video? This action cannot be undone."]
}
};
IDialogReference dialog =
await DialogService.ShowAsync<DeleteConfirmDialog>(L["Delete Video"], parameters,
new DialogOptions
{
MaxWidth = MaxWidth.ExtraSmall,
FullWidth = true
});
DialogResult result = await dialog.Result;
if(result is not { Canceled: false }) return;
(bool succeeded, string errorMessage) =
await SoftwareService.DeleteVideoAsync(video.Id ?? 0);
if(succeeded)
{
_successMessage = L["Video deleted successfully."];
await LoadDataAsync();
}
else
{
_errorMessage = string.IsNullOrWhiteSpace(errorMessage) ? L["Failed to delete video."] : errorMessage;
}
}
}

View File

@@ -1245,4 +1245,96 @@
<value>Werbegrafik erfolgreich gelöscht.</value>
<comment>Promo art deleted successfully.</comment>
</data>
<data name="Videos" xml:space="preserve">
<value>Videos</value>
<comment>Videos</comment>
</data>
<data name="Video Management" xml:space="preserve">
<value>Videoverwaltung</value>
<comment>Video Management</comment>
</data>
<data name="Manage software videos." xml:space="preserve">
<value>Software-Videos verwalten.</value>
<comment>Manage software videos.</comment>
</data>
<data name="Link Video" xml:space="preserve">
<value>Video verknüpfen</value>
<comment>Link Video</comment>
</data>
<data name="YouTube URL or Video ID" xml:space="preserve">
<value>YouTube-URL oder Video-ID</value>
<comment>YouTube URL or Video ID</comment>
</data>
<data name="Detected Video ID" xml:space="preserve">
<value>Erkannte Video-ID</value>
<comment>Detected Video ID</comment>
</data>
<data name="Provider" xml:space="preserve">
<value>Anbieter</value>
<comment>Provider</comment>
</data>
<data name="Video ID" xml:space="preserve">
<value>Video-ID</value>
<comment>Video ID</comment>
</data>
<data name="Video Title" xml:space="preserve">
<value>Videotitel</value>
<comment>Video Title</comment>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
<comment>Title</comment>
</data>
<data name="Advanced" xml:space="preserve">
<value>Erweitert</value>
<comment>Advanced</comment>
</data>
<data name="Provider name (e.g. YouTube)." xml:space="preserve">
<value>Anbietername (z. B. YouTube).</value>
<comment>Provider name (e.g. YouTube).</comment>
</data>
<data name="Use this when the URL above cannot be parsed (non-YouTube providers)." xml:space="preserve">
<value>Verwenden Sie dies, wenn die obige URL nicht analysiert werden kann (Nicht-YouTube-Anbieter).</value>
<comment>Use this when the URL above cannot be parsed (non-YouTube providers).</comment>
</data>
<data name="Saving..." xml:space="preserve">
<value>Speichern...</value>
<comment>Saving...</comment>
</data>
<data name="No videos found for this software." xml:space="preserve">
<value>Keine Videos für diese Software gefunden.</value>
<comment>No videos found for this software.</comment>
</data>
<data name="Delete Video" xml:space="preserve">
<value>Video löschen</value>
<comment>Delete Video</comment>
</data>
<data name="Are you sure you want to delete this video? This action cannot be undone." xml:space="preserve">
<value>Möchten Sie dieses Video wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</value>
<comment>Are you sure you want to delete this video? This action cannot be undone.</comment>
</data>
<data name="Video linked successfully." xml:space="preserve">
<value>Video erfolgreich verknüpft.</value>
<comment>Video linked successfully.</comment>
</data>
<data name="Video updated successfully." xml:space="preserve">
<value>Video erfolgreich aktualisiert.</value>
<comment>Video updated successfully.</comment>
</data>
<data name="Video deleted successfully." xml:space="preserve">
<value>Video erfolgreich gelöscht.</value>
<comment>Video deleted successfully.</comment>
</data>
<data name="Failed to link video." xml:space="preserve">
<value>Video konnte nicht verknüpft werden.</value>
<comment>Failed to link video.</comment>
</data>
<data name="Failed to update video." xml:space="preserve">
<value>Video konnte nicht aktualisiert werden.</value>
<comment>Failed to update video.</comment>
</data>
<data name="Failed to delete video." xml:space="preserve">
<value>Video konnte nicht gelöscht werden.</value>
<comment>Failed to delete video.</comment>
</data>
</root>

View File

@@ -1139,4 +1139,76 @@
<data name="Promo art deleted successfully." xml:space="preserve">
<value>Promo art deleted successfully.</value>
</data>
<data name="Videos" xml:space="preserve">
<value>Videos</value>
</data>
<data name="Video Management" xml:space="preserve">
<value>Video Management</value>
</data>
<data name="Manage software videos." xml:space="preserve">
<value>Manage software videos.</value>
</data>
<data name="Link Video" xml:space="preserve">
<value>Link Video</value>
</data>
<data name="YouTube URL or Video ID" xml:space="preserve">
<value>YouTube URL or Video ID</value>
</data>
<data name="Detected Video ID" xml:space="preserve">
<value>Detected Video ID</value>
</data>
<data name="Provider" xml:space="preserve">
<value>Provider</value>
</data>
<data name="Video ID" xml:space="preserve">
<value>Video ID</value>
</data>
<data name="Video Title" xml:space="preserve">
<value>Video Title</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="Advanced" xml:space="preserve">
<value>Advanced</value>
</data>
<data name="Provider name (e.g. YouTube)." xml:space="preserve">
<value>Provider name (e.g. YouTube).</value>
</data>
<data name="Use this when the URL above cannot be parsed (non-YouTube providers)." xml:space="preserve">
<value>Use this when the URL above cannot be parsed (non-YouTube providers).</value>
</data>
<data name="Saving..." xml:space="preserve">
<value>Saving...</value>
</data>
<data name="No videos found for this software." xml:space="preserve">
<value>No videos found for this software.</value>
</data>
<data name="Delete Video" xml:space="preserve">
<value>Delete Video</value>
</data>
<data name="Are you sure you want to delete this video? This action cannot be undone." xml:space="preserve">
<value>Are you sure you want to delete this video? This action cannot be undone.</value>
</data>
<data name="Video linked successfully." xml:space="preserve">
<value>Video linked successfully.</value>
</data>
<data name="Video updated successfully." xml:space="preserve">
<value>Video updated successfully.</value>
</data>
<data name="Video deleted successfully." xml:space="preserve">
<value>Video deleted successfully.</value>
</data>
<data name="Failed to link video." xml:space="preserve">
<value>Failed to link video.</value>
</data>
<data name="Failed to update video." xml:space="preserve">
<value>Failed to update video.</value>
</data>
<data name="Failed to delete video." xml:space="preserve">
<value>Failed to delete video.</value>
</data>
<data name="Back to Software" xml:space="preserve">
<value>Back to Software</value>
</data>
</root>

View File

@@ -1187,4 +1187,96 @@
<value>Arte promocional eliminado correctamente.</value>
<comment>Promo art deleted successfully.</comment>
</data>
<data name="Videos" xml:space="preserve">
<value>Vídeos</value>
<comment>Videos</comment>
</data>
<data name="Video Management" xml:space="preserve">
<value>Gestión de vídeos</value>
<comment>Video Management</comment>
</data>
<data name="Manage software videos." xml:space="preserve">
<value>Gestionar vídeos del software.</value>
<comment>Manage software videos.</comment>
</data>
<data name="Link Video" xml:space="preserve">
<value>Vincular vídeo</value>
<comment>Link Video</comment>
</data>
<data name="YouTube URL or Video ID" xml:space="preserve">
<value>URL de YouTube o ID del vídeo</value>
<comment>YouTube URL or Video ID</comment>
</data>
<data name="Detected Video ID" xml:space="preserve">
<value>ID de vídeo detectado</value>
<comment>Detected Video ID</comment>
</data>
<data name="Provider" xml:space="preserve">
<value>Proveedor</value>
<comment>Provider</comment>
</data>
<data name="Video ID" xml:space="preserve">
<value>ID del vídeo</value>
<comment>Video ID</comment>
</data>
<data name="Video Title" xml:space="preserve">
<value>Título del vídeo</value>
<comment>Video Title</comment>
</data>
<data name="Title" xml:space="preserve">
<value>Título</value>
<comment>Title</comment>
</data>
<data name="Advanced" xml:space="preserve">
<value>Avanzado</value>
<comment>Advanced</comment>
</data>
<data name="Provider name (e.g. YouTube)." xml:space="preserve">
<value>Nombre del proveedor (p. ej., YouTube).</value>
<comment>Provider name (e.g. YouTube).</comment>
</data>
<data name="Use this when the URL above cannot be parsed (non-YouTube providers)." xml:space="preserve">
<value>Úselo cuando la URL anterior no pueda analizarse (proveedores distintos de YouTube).</value>
<comment>Use this when the URL above cannot be parsed (non-YouTube providers).</comment>
</data>
<data name="Saving..." xml:space="preserve">
<value>Guardando...</value>
<comment>Saving...</comment>
</data>
<data name="No videos found for this software." xml:space="preserve">
<value>No se encontraron vídeos para este software.</value>
<comment>No videos found for this software.</comment>
</data>
<data name="Delete Video" xml:space="preserve">
<value>Eliminar vídeo</value>
<comment>Delete Video</comment>
</data>
<data name="Are you sure you want to delete this video? This action cannot be undone." xml:space="preserve">
<value>¿Está seguro de que desea eliminar este vídeo? Esta acción no se puede deshacer.</value>
<comment>Are you sure you want to delete this video? This action cannot be undone.</comment>
</data>
<data name="Video linked successfully." xml:space="preserve">
<value>Vídeo vinculado correctamente.</value>
<comment>Video linked successfully.</comment>
</data>
<data name="Video updated successfully." xml:space="preserve">
<value>Vídeo actualizado correctamente.</value>
<comment>Video updated successfully.</comment>
</data>
<data name="Video deleted successfully." xml:space="preserve">
<value>Vídeo eliminado correctamente.</value>
<comment>Video deleted successfully.</comment>
</data>
<data name="Failed to link video." xml:space="preserve">
<value>No se pudo vincular el vídeo.</value>
<comment>Failed to link video.</comment>
</data>
<data name="Failed to update video." xml:space="preserve">
<value>No se pudo actualizar el vídeo.</value>
<comment>Failed to update video.</comment>
</data>
<data name="Failed to delete video." xml:space="preserve">
<value>No se pudo eliminar el vídeo.</value>
<comment>Failed to delete video.</comment>
</data>
</root>

View File

@@ -1245,4 +1245,96 @@
<value>Art promotionnel supprimé avec succès.</value>
<comment>Promo art deleted successfully.</comment>
</data>
<data name="Videos" xml:space="preserve">
<value>Vidéos</value>
<comment>Videos</comment>
</data>
<data name="Video Management" xml:space="preserve">
<value>Gestion des vidéos</value>
<comment>Video Management</comment>
</data>
<data name="Manage software videos." xml:space="preserve">
<value>Gérer les vidéos du logiciel.</value>
<comment>Manage software videos.</comment>
</data>
<data name="Link Video" xml:space="preserve">
<value>Associer une vidéo</value>
<comment>Link Video</comment>
</data>
<data name="YouTube URL or Video ID" xml:space="preserve">
<value>URL YouTube ou identifiant de la vidéo</value>
<comment>YouTube URL or Video ID</comment>
</data>
<data name="Detected Video ID" xml:space="preserve">
<value>Identifiant de vidéo détecté</value>
<comment>Detected Video ID</comment>
</data>
<data name="Provider" xml:space="preserve">
<value>Fournisseur</value>
<comment>Provider</comment>
</data>
<data name="Video ID" xml:space="preserve">
<value>Identifiant de la vidéo</value>
<comment>Video ID</comment>
</data>
<data name="Video Title" xml:space="preserve">
<value>Titre de la vidéo</value>
<comment>Video Title</comment>
</data>
<data name="Title" xml:space="preserve">
<value>Titre</value>
<comment>Title</comment>
</data>
<data name="Advanced" xml:space="preserve">
<value>Avancé</value>
<comment>Advanced</comment>
</data>
<data name="Provider name (e.g. YouTube)." xml:space="preserve">
<value>Nom du fournisseur (par ex. YouTube).</value>
<comment>Provider name (e.g. YouTube).</comment>
</data>
<data name="Use this when the URL above cannot be parsed (non-YouTube providers)." xml:space="preserve">
<value>Utilisez ceci lorsque l'URL ci-dessus ne peut pas être analysée (fournisseurs autres que YouTube).</value>
<comment>Use this when the URL above cannot be parsed (non-YouTube providers).</comment>
</data>
<data name="Saving..." xml:space="preserve">
<value>Enregistrement...</value>
<comment>Saving...</comment>
</data>
<data name="No videos found for this software." xml:space="preserve">
<value>Aucune vidéo trouvée pour ce logiciel.</value>
<comment>No videos found for this software.</comment>
</data>
<data name="Delete Video" xml:space="preserve">
<value>Supprimer la vidéo</value>
<comment>Delete Video</comment>
</data>
<data name="Are you sure you want to delete this video? This action cannot be undone." xml:space="preserve">
<value>Êtes-vous sûr de vouloir supprimer cette vidéo ? Cette action ne peut pas être annulée.</value>
<comment>Are you sure you want to delete this video? This action cannot be undone.</comment>
</data>
<data name="Video linked successfully." xml:space="preserve">
<value>Vidéo associée avec succès.</value>
<comment>Video linked successfully.</comment>
</data>
<data name="Video updated successfully." xml:space="preserve">
<value>Vidéo mise à jour avec succès.</value>
<comment>Video updated successfully.</comment>
</data>
<data name="Video deleted successfully." xml:space="preserve">
<value>Vidéo supprimée avec succès.</value>
<comment>Video deleted successfully.</comment>
</data>
<data name="Failed to link video." xml:space="preserve">
<value>Échec de l'association de la vidéo.</value>
<comment>Failed to link video.</comment>
</data>
<data name="Failed to update video." xml:space="preserve">
<value>Échec de la mise à jour de la vidéo.</value>
<comment>Failed to update video.</comment>
</data>
<data name="Failed to delete video." xml:space="preserve">
<value>Échec de la suppression de la vidéo.</value>
<comment>Failed to delete video.</comment>
</data>
</root>

View File

@@ -1157,4 +1157,96 @@
<value>Arte promozionale eliminata con successo.</value>
<comment>Promo art deleted successfully.</comment>
</data>
<data name="Videos" xml:space="preserve">
<value>Video</value>
<comment>Videos</comment>
</data>
<data name="Video Management" xml:space="preserve">
<value>Gestione video</value>
<comment>Video Management</comment>
</data>
<data name="Manage software videos." xml:space="preserve">
<value>Gestisci i video del software.</value>
<comment>Manage software videos.</comment>
</data>
<data name="Link Video" xml:space="preserve">
<value>Collega video</value>
<comment>Link Video</comment>
</data>
<data name="YouTube URL or Video ID" xml:space="preserve">
<value>URL di YouTube o ID del video</value>
<comment>YouTube URL or Video ID</comment>
</data>
<data name="Detected Video ID" xml:space="preserve">
<value>ID del video rilevato</value>
<comment>Detected Video ID</comment>
</data>
<data name="Provider" xml:space="preserve">
<value>Fornitore</value>
<comment>Provider</comment>
</data>
<data name="Video ID" xml:space="preserve">
<value>ID del video</value>
<comment>Video ID</comment>
</data>
<data name="Video Title" xml:space="preserve">
<value>Titolo del video</value>
<comment>Video Title</comment>
</data>
<data name="Title" xml:space="preserve">
<value>Titolo</value>
<comment>Title</comment>
</data>
<data name="Advanced" xml:space="preserve">
<value>Avanzate</value>
<comment>Advanced</comment>
</data>
<data name="Provider name (e.g. YouTube)." xml:space="preserve">
<value>Nome del fornitore (es. YouTube).</value>
<comment>Provider name (e.g. YouTube).</comment>
</data>
<data name="Use this when the URL above cannot be parsed (non-YouTube providers)." xml:space="preserve">
<value>Usalo quando l'URL sopra non può essere analizzato (fornitori diversi da YouTube).</value>
<comment>Use this when the URL above cannot be parsed (non-YouTube providers).</comment>
</data>
<data name="Saving..." xml:space="preserve">
<value>Salvataggio in corso...</value>
<comment>Saving...</comment>
</data>
<data name="No videos found for this software." xml:space="preserve">
<value>Nessun video trovato per questo software.</value>
<comment>No videos found for this software.</comment>
</data>
<data name="Delete Video" xml:space="preserve">
<value>Elimina video</value>
<comment>Delete Video</comment>
</data>
<data name="Are you sure you want to delete this video? This action cannot be undone." xml:space="preserve">
<value>Sei sicuro di voler eliminare questo video? Questa azione non può essere annullata.</value>
<comment>Are you sure you want to delete this video? This action cannot be undone.</comment>
</data>
<data name="Video linked successfully." xml:space="preserve">
<value>Video collegato con successo.</value>
<comment>Video linked successfully.</comment>
</data>
<data name="Video updated successfully." xml:space="preserve">
<value>Video aggiornato con successo.</value>
<comment>Video updated successfully.</comment>
</data>
<data name="Video deleted successfully." xml:space="preserve">
<value>Video eliminato con successo.</value>
<comment>Video deleted successfully.</comment>
</data>
<data name="Failed to link video." xml:space="preserve">
<value>Impossibile collegare il video.</value>
<comment>Failed to link video.</comment>
</data>
<data name="Failed to update video." xml:space="preserve">
<value>Impossibile aggiornare il video.</value>
<comment>Failed to update video.</comment>
</data>
<data name="Failed to delete video." xml:space="preserve">
<value>Impossibile eliminare il video.</value>
<comment>Failed to delete video.</comment>
</data>
</root>

View File

@@ -1514,4 +1514,69 @@ public class SoftwareService(Marechai.ApiClient.Client client, IRequestAdapter r
return [];
}
}
public async Task<(SoftwareVideoDto dto, string error)> CreateVideoAsync(int softwareId, string provider,
string videoId,
string title)
{
try
{
var dto = await client.Software[softwareId]
.Videos.PostAsync(new CreateSoftwareVideoRequest
{
Provider = provider,
VideoId = videoId,
Title = title
});
return (dto, null);
}
catch(ApiException ex)
{
return (null, ExtractErrorMessage(ex));
}
catch(Exception ex)
{
return (null, ex.Message);
}
}
public async Task<(bool succeeded, string error)> UpdateVideoTitleAsync(long id, string title)
{
try
{
await client.Software.Videos[id].PutAsync(new UpdateSoftwareVideoRequest
{
Title = title
});
return (true, null);
}
catch(ApiException ex)
{
return (false, ExtractErrorMessage(ex));
}
catch(Exception ex)
{
return (false, ex.Message);
}
}
public async Task<(bool succeeded, string error)> DeleteVideoAsync(long id)
{
try
{
await client.Software.Videos[id].DeleteAsync();
return (true, null);
}
catch(ApiException ex)
{
return (false, ExtractErrorMessage(ex));
}
catch(Exception ex)
{
return (false, ex.Message);
}
}
}