0a7dd9b84c
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 51s
- node-agent: internal/metrics — read CPU% (GetSystemTimes), RAM (GlobalMemoryStatusEx), disk used%/total (GetDiskFreeSpaceEx) via stdlib kernel32 (no external dep; windows build + non-windows stub). Heartbeat now reports cpu_pct/ram_available_mb/disk_used_pct/ disk_total_gb + ae_running. - render-svc: heartbeat persists last_disk_pct + disk_total_gb (migration 29); RenderNode model + node SELECT/scan carry them. - admin: rewrite NodesTable to the real RenderNode shape (fixes a pre-existing items/V2Node mismatch that left the list empty) + a CPU/RAM/disk bars column + stale-heartbeat flag. - assets-bundle ingestion: ProjectMediaBundle (jszip) auto-maps project.zip → project/scene image/demo/colour + music; PatchProject gains image/full_demo/shared_colors_svg. - scan: RGBA (4-number) colours recognised + frshare single-int controls detected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
566 lines
29 KiB
C#
566 lines
29 KiB
C#
using FlatRender.ContentSvc.Domain.Entities;
|
|
using FlatRender.ContentSvc.Domain.Enums;
|
|
using FlatRender.ContentSvc.Infrastructure.Data;
|
|
using FlatRender.ContentSvc.Models.Requests;
|
|
using FlatRender.ContentSvc.Models.Responses;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace FlatRender.ContentSvc.Application.Services;
|
|
|
|
public class TemplateService(ContentDbContext db)
|
|
{
|
|
public async Task<PagedResponse<ContainerSummaryResponse>> GetContainersAsync(ContainerListRequest req)
|
|
{
|
|
var q = db.ProjectContainers
|
|
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
|
|
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
|
|
.AsQueryable();
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
|
q = q.Where(x => EF.Functions.ILike(x.Name, $"%{req.Search}%") ||
|
|
EF.Functions.ILike(x.Slug, $"%{req.Search}%"));
|
|
|
|
if (req.CategoryId.HasValue)
|
|
q = q.Where(x => x.ContainerCategories.Any(cc => cc.CategoryId == req.CategoryId));
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.TagSlug))
|
|
q = q.Where(x => x.ContainerTags.Any(ct => ct.Tag.Slug == req.TagSlug));
|
|
|
|
if (req.IsPublished.HasValue)
|
|
q = q.Where(x => x.IsPublished == req.IsPublished);
|
|
|
|
if (req.IsPremium.HasValue)
|
|
q = q.Where(x => x.IsPremium == req.IsPremium);
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.Mode))
|
|
{
|
|
if (Enum.TryParse<ChooseMode>(req.Mode, true, out var mode))
|
|
q = q.Where(x => x.PrimaryMode == mode);
|
|
}
|
|
|
|
q = req.Sort switch
|
|
{
|
|
"sort_asc" => q.OrderBy(x => x.Sort),
|
|
"name_asc" => q.OrderBy(x => x.Name),
|
|
"view_count_desc" => q.OrderByDescending(x => x.ViewCount),
|
|
"use_count_desc" or "popular" => q.OrderByDescending(x => x.UseCount).ThenByDescending(x => x.ViewCount),
|
|
"rating_desc" => q.OrderByDescending(x => x.RateAvg).ThenByDescending(x => x.RateCount),
|
|
_ => q.OrderByDescending(x => x.SortDate)
|
|
};
|
|
|
|
var total = await q.LongCountAsync();
|
|
var items = await q.Skip((req.Page - 1) * req.PageSize).Take(req.PageSize).ToListAsync();
|
|
|
|
return new PagedResponse<ContainerSummaryResponse>(
|
|
items.Select(MapContainerSummary),
|
|
new PaginationMeta(req.Page, req.PageSize, total, (int)Math.Ceiling((double)total / req.PageSize))
|
|
);
|
|
}
|
|
|
|
public async Task<ContainerDetailResponse> GetContainerBySlugAsync(string slug)
|
|
{
|
|
var container = await db.ProjectContainers
|
|
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
|
|
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
|
|
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
|
|
.FirstOrDefaultAsync(x => x.Slug == slug)
|
|
?? throw new KeyNotFoundException($"Container '{slug}' not found");
|
|
|
|
return MapContainerDetail(container);
|
|
}
|
|
|
|
public async Task<ContainerDetailResponse> GetContainerByIdAsync(Guid id)
|
|
{
|
|
var container = await db.ProjectContainers
|
|
.Include(x => x.ContainerCategories).ThenInclude(x => x.Category)
|
|
.Include(x => x.ContainerTags).ThenInclude(x => x.Tag)
|
|
.Include(x => x.Projects.Where(p => p.DeletedAt == null))
|
|
.FirstOrDefaultAsync(x => x.Id == id)
|
|
?? throw new KeyNotFoundException($"Container {id} not found");
|
|
|
|
return MapContainerDetail(container);
|
|
}
|
|
|
|
public async Task<ContainerDetailResponse> CreateContainerAsync(CreateContainerRequest req)
|
|
{
|
|
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
|
|
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
|
|
|
|
var container = new ProjectContainer
|
|
{
|
|
Slug = req.Slug, Name = req.Name, Description = req.Description, Keywords = req.Keywords,
|
|
NewsText = req.NewsText, Image = req.Image, Demo = req.Demo, FullDemo = req.FullDemo,
|
|
MiniDemo = req.MiniDemo, DemoScriptTag = req.DemoScriptTag, IsPublished = req.IsPublished,
|
|
IsPremium = req.IsPremium, IsMockup = req.IsMockup, PrimaryMode = mode, Sort = req.Sort,
|
|
SortDate = DateTime.UtcNow
|
|
};
|
|
|
|
db.ProjectContainers.Add(container);
|
|
await db.SaveChangesAsync();
|
|
|
|
await SyncContainerCategoriesAsync(container.Id, req.CategoryIds);
|
|
await SyncContainerTagsAsync(container.Id, req.TagIds);
|
|
|
|
return await GetContainerByIdAsync(container.Id);
|
|
}
|
|
|
|
public async Task<ContainerDetailResponse> UpdateContainerAsync(Guid id, UpdateContainerRequest req)
|
|
{
|
|
var container = await db.ProjectContainers.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Container {id} not found");
|
|
|
|
if (!Enum.TryParse<ChooseMode>(req.PrimaryMode, true, out var mode))
|
|
throw new ArgumentException($"Invalid PrimaryMode: {req.PrimaryMode}");
|
|
|
|
container.Slug = req.Slug; container.Name = req.Name; container.Description = req.Description;
|
|
container.Keywords = req.Keywords; container.NewsText = req.NewsText; container.Image = req.Image;
|
|
container.Demo = req.Demo; container.FullDemo = req.FullDemo; container.MiniDemo = req.MiniDemo;
|
|
container.DemoScriptTag = req.DemoScriptTag; container.IsPublished = req.IsPublished;
|
|
container.IsPremium = req.IsPremium; container.IsMockup = req.IsMockup;
|
|
container.PrimaryMode = mode; container.Sort = req.Sort; container.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await db.SaveChangesAsync();
|
|
await SyncContainerCategoriesAsync(id, req.CategoryIds);
|
|
await SyncContainerTagsAsync(id, req.TagIds);
|
|
|
|
return await GetContainerByIdAsync(id);
|
|
}
|
|
|
|
public async Task DeleteContainerAsync(Guid id)
|
|
{
|
|
var container = await db.ProjectContainers.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Container {id} not found");
|
|
container.DeletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Lightweight ranking update — set a template's manual sort weight without a full edit.</summary>
|
|
public async Task SetContainerSortAsync(Guid id, int sort)
|
|
{
|
|
var container = await db.ProjectContainers.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Container {id} not found");
|
|
container.Sort = sort;
|
|
container.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id)
|
|
{
|
|
var project = await db.Projects
|
|
.Include(x => x.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(x => x.RepeaterItems)
|
|
.Include(x => x.Scenes).ThenInclude(x => x.ContentElements)
|
|
.Include(x => x.Scenes).ThenInclude(x => x.ColorElements)
|
|
.Include(x => x.Scenes).ThenInclude(x => x.ColorPresets).ThenInclude(x => x.Items)
|
|
.Include(x => x.Scenes).ThenInclude(x => x.Characters).ThenInclude(x => x.Controllers).ThenInclude(x => x.Options)
|
|
.Include(x => x.SharedColors)
|
|
.Include(x => x.SharedLayers)
|
|
.FirstOrDefaultAsync(x => x.Id == id)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
|
|
return MapProjectDetail(project);
|
|
}
|
|
|
|
public async Task<ProjectDetailResponse> CreateProjectAsync(CreateProjectRequest req)
|
|
{
|
|
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
|
|
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
|
|
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
|
|
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
|
|
|
|
var project = new Project
|
|
{
|
|
ContainerId = req.ContainerId, ProjectServerId = req.ProjectServerId, Name = req.Name,
|
|
Description = req.Description, Image = req.Image, FullDemo = req.FullDemo,
|
|
DemoScriptTag = req.DemoScriptTag, DownloadLink = req.DownloadLink, Folder = req.Folder,
|
|
OriginalWidth = req.OriginalWidth, OriginalHeight = req.OriginalHeight, Aspect = req.Aspect,
|
|
ProjectDurationSec = req.ProjectDurationSec, MinDurationSec = req.MinDurationSec,
|
|
MaxDurationSec = req.MaxDurationSec, FreeFps = req.FreeFps, ChooseMode = chooseMode,
|
|
Resolution = resolution, VipFactor = req.VipFactor, RenderAepComp = req.RenderAepComp,
|
|
IsPublished = req.IsPublished, Sort = req.Sort
|
|
};
|
|
|
|
db.Projects.Add(project);
|
|
await db.SaveChangesAsync();
|
|
return await GetProjectDetailAsync(project.Id);
|
|
}
|
|
|
|
/// <summary>Clone a project (all scenes / content-elements / colour-elements / presets /
|
|
/// shared colours / shared layers) into a NEW project for a different aspect ratio. All
|
|
/// keys/values are copied identically — only the output dimensions (+ optional name/container)
|
|
/// change. The AEP file is intentionally NOT copied: each aspect has its own .aep, attached
|
|
/// after. The duplicate starts unpublished until its .aep is set.</summary>
|
|
public async Task<ProjectDetailResponse> DuplicateProjectAsync(Guid id, DuplicateProjectRequest req)
|
|
{
|
|
var src = await db.Projects
|
|
.Include(p => p.Scenes.Where(s => s.DeletedAt == null)).ThenInclude(s => s.RepeaterItems)
|
|
.Include(p => p.Scenes).ThenInclude(s => s.ContentElements)
|
|
.Include(p => p.Scenes).ThenInclude(s => s.ColorElements)
|
|
.Include(p => p.Scenes).ThenInclude(s => s.ColorPresets).ThenInclude(cp => cp.Items)
|
|
.Include(p => p.SharedColors)
|
|
.Include(p => p.SharedLayers)
|
|
.Include(p => p.SharedColorPresets).ThenInclude(sp => sp.Items)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(p => p.Id == id)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
|
|
var resolution = src.Resolution;
|
|
if (req.Resolution != null)
|
|
{
|
|
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var r))
|
|
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
|
|
resolution = r;
|
|
}
|
|
|
|
var np = new Project
|
|
{
|
|
ContainerId = req.ContainerId ?? src.ContainerId,
|
|
ProjectServerId = src.ProjectServerId,
|
|
Name = string.IsNullOrWhiteSpace(req.Name) ? $"{src.Name} ({req.Aspect ?? src.Aspect})" : req.Name!,
|
|
Description = src.Description, Image = src.Image, FullDemo = src.FullDemo,
|
|
DemoScriptTag = src.DemoScriptTag, DownloadLink = src.DownloadLink, Folder = src.Folder,
|
|
// AEP fields deliberately left null — this aspect gets its own .aep.
|
|
OriginalWidth = req.OriginalWidth ?? src.OriginalWidth,
|
|
OriginalHeight = req.OriginalHeight ?? src.OriginalHeight,
|
|
Aspect = req.Aspect ?? src.Aspect,
|
|
ProjectDurationSec = src.ProjectDurationSec, MinDurationSec = src.MinDurationSec,
|
|
MaxDurationSec = src.MaxDurationSec, FreeFps = src.FreeFps, ChooseMode = src.ChooseMode,
|
|
Resolution = resolution, VipFactor = src.VipFactor, RenderAepComp = src.RenderAepComp,
|
|
SharedLayerImage = src.SharedLayerImage, SharedColorsSvg = src.SharedColorsSvg,
|
|
SharedColorPresetsSvg = src.SharedColorPresetsSvg,
|
|
IsPublished = false, Sort = src.Sort,
|
|
};
|
|
|
|
foreach (var s in src.Scenes.OrderBy(x => x.Sort))
|
|
{
|
|
var ns = new Scene
|
|
{
|
|
Key = s.Key, Title = s.Title, LocalizedTitle = s.LocalizedTitle, SceneType = s.SceneType,
|
|
Image = s.Image, Demo = s.Demo, SceneColorSvg = s.SceneColorSvg, SnapshotUrl = s.SnapshotUrl,
|
|
GenerateKf = s.GenerateKf, DefaultDurationSec = s.DefaultDurationSec, MinDurationSec = s.MinDurationSec,
|
|
MaxDurationSec = s.MaxDurationSec, OverlapAtEndSec = s.OverlapAtEndSec, CanHandleDuration = s.CanHandleDuration,
|
|
ManualColorSelection = s.ManualColorSelection, Sort = s.Sort, IsActive = s.IsActive,
|
|
};
|
|
|
|
// Repeater items first, so repeater-scoped content elements can re-parent to the clones.
|
|
var repMap = new Dictionary<Guid, RepeaterItem>();
|
|
foreach (var ri in s.RepeaterItems)
|
|
{
|
|
var nri = new RepeaterItem
|
|
{
|
|
Title = ri.Title, RepeatBoxKey = ri.RepeatBoxKey, RepeatItemKey = ri.RepeatItemKey,
|
|
MaxRepeatCount = ri.MaxRepeatCount, UserCanChangeSort = ri.UserCanChangeSort,
|
|
RepeatSortStrategy = ri.RepeatSortStrategy, Sort = ri.Sort,
|
|
};
|
|
repMap[ri.Id] = nri;
|
|
ns.RepeaterItems.Add(nri);
|
|
}
|
|
|
|
foreach (var ce in s.ContentElements)
|
|
{
|
|
var nce = CloneContentElement(ce);
|
|
ns.ContentElements.Add(nce);
|
|
if (ce.RepeaterItemId.HasValue && repMap.TryGetValue(ce.RepeaterItemId.Value, out var nri))
|
|
nri.ContentElements.Add(nce); // sets RepeaterItemId on the clone
|
|
}
|
|
|
|
foreach (var col in s.ColorElements)
|
|
ns.ColorElements.Add(new SceneColorElement
|
|
{
|
|
ElementKey = col.ElementKey, Title = col.Title, Icon = col.Icon,
|
|
AttrValue = col.AttrValue, DefaultColor = col.DefaultColor, Sort = col.Sort,
|
|
});
|
|
|
|
foreach (var cp in s.ColorPresets)
|
|
{
|
|
var ncp = new SceneColorPreset { Name = cp.Name, Sort = cp.Sort };
|
|
foreach (var it in cp.Items)
|
|
ncp.Items.Add(new SceneColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
|
|
ns.ColorPresets.Add(ncp);
|
|
}
|
|
|
|
np.Scenes.Add(ns);
|
|
}
|
|
|
|
foreach (var sc in src.SharedColors)
|
|
np.SharedColors.Add(new SharedColor
|
|
{
|
|
ElementKey = sc.ElementKey, Title = sc.Title, Icon = sc.Icon,
|
|
AttrValue = sc.AttrValue, DefaultColor = sc.DefaultColor, Sort = sc.Sort,
|
|
});
|
|
|
|
foreach (var sp in src.SharedColorPresets)
|
|
{
|
|
var nsp = new SharedColorPreset { Name = sp.Name, Sort = sp.Sort };
|
|
foreach (var it in sp.Items)
|
|
nsp.Items.Add(new SharedColorPresetItem { ElementKey = it.ElementKey, Value = it.Value, Sort = it.Sort });
|
|
np.SharedColorPresets.Add(nsp);
|
|
}
|
|
|
|
foreach (var sl in src.SharedLayers)
|
|
np.SharedLayers.Add(CloneSharedLayer(sl));
|
|
|
|
db.Projects.Add(np);
|
|
await db.SaveChangesAsync();
|
|
return await GetProjectDetailAsync(np.Id);
|
|
}
|
|
|
|
private static SceneContentElement CloneContentElement(SceneContentElement e) => new()
|
|
{
|
|
Key = e.Key, Title = e.Title, LocalizedTitle = e.LocalizedTitle, Hint = e.Hint,
|
|
Type = e.Type, DefaultValue = e.DefaultValue,
|
|
FontId = e.FontId, FontFace = e.FontFace, FontFaceName = e.FontFaceName, FontSize = e.FontSize,
|
|
DefaultFontSize = e.DefaultFontSize, DefaultFontFace = e.DefaultFontFace,
|
|
IsFontChangeable = e.IsFontChangeable, IsFontSizeChangeable = e.IsFontSizeChangeable,
|
|
Justify = e.Justify, CanJustify = e.CanJustify, PositionInContainer = e.PositionInContainer,
|
|
IsTextBox = e.IsTextBox, MaxSize = e.MaxSize, DirectionLayerKey = e.DirectionLayerKey,
|
|
DirectionLayerValue = e.DirectionLayerValue, VideoSupport = e.VideoSupport,
|
|
MinDurationSec = e.MinDurationSec, MaxDurationSec = e.MaxDurationSec, Width = e.Width, Height = e.Height,
|
|
Thumbnail = e.Thumbnail, MappedList = e.MappedList, CounterMode = e.CounterMode,
|
|
AiInputType = e.AiInputType, IsHidden = e.IsHidden, IsFocused = e.IsFocused,
|
|
OpacityControllerKey = e.OpacityControllerKey,
|
|
Dp1Image = e.Dp1Image, Dp1Title = e.Dp1Title, Dp2Image = e.Dp2Image, Dp2Title = e.Dp2Title,
|
|
Dp3Image = e.Dp3Image, Dp3Title = e.Dp3Title, Dp4Image = e.Dp4Image, Dp4Title = e.Dp4Title,
|
|
VirtualCount = e.VirtualCount, Sort = e.Sort,
|
|
};
|
|
|
|
private static SharedLayer CloneSharedLayer(SharedLayer l) => new()
|
|
{
|
|
Key = l.Key, Title = l.Title, LocalizedTitle = l.LocalizedTitle, Hint = l.Hint,
|
|
Type = l.Type, DefaultValue = l.DefaultValue,
|
|
FontId = l.FontId, FontFace = l.FontFace, FontFaceName = l.FontFaceName, FontSize = l.FontSize,
|
|
DefaultFontSize = l.DefaultFontSize, DefaultFontFace = l.DefaultFontFace,
|
|
IsFontChangeable = l.IsFontChangeable, IsFontSizeChangeable = l.IsFontSizeChangeable,
|
|
Justify = l.Justify, CanJustify = l.CanJustify, PositionInContainer = l.PositionInContainer,
|
|
IsTextBox = l.IsTextBox, MaxSize = l.MaxSize, DirectionLayerKey = l.DirectionLayerKey,
|
|
DirectionLayerValue = l.DirectionLayerValue, VideoSupport = l.VideoSupport,
|
|
MinDurationSec = l.MinDurationSec, MaxDurationSec = l.MaxDurationSec, Width = l.Width, Height = l.Height,
|
|
Thumbnail = l.Thumbnail, MappedList = l.MappedList, CounterMode = l.CounterMode,
|
|
AiInputType = l.AiInputType, IsHidden = l.IsHidden, IsFocused = l.IsFocused,
|
|
Dp1Image = l.Dp1Image, Dp1Title = l.Dp1Title, Dp2Image = l.Dp2Image, Dp2Title = l.Dp2Title,
|
|
Dp3Image = l.Dp3Image, Dp3Title = l.Dp3Title, Dp4Image = l.Dp4Image, Dp4Title = l.Dp4Title,
|
|
VirtualCount = l.VirtualCount, Sort = l.Sort,
|
|
};
|
|
|
|
public async Task<ProjectDetailResponse> UpdateProjectAsync(Guid id, UpdateProjectRequest req)
|
|
{
|
|
var project = await db.Projects.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
|
|
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
|
|
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
|
|
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
|
|
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
|
|
|
|
project.Name = req.Name; project.Description = req.Description; project.Image = req.Image;
|
|
project.FullDemo = req.FullDemo; project.DemoScriptTag = req.DemoScriptTag;
|
|
project.DownloadLink = req.DownloadLink; project.Folder = req.Folder;
|
|
project.OriginalWidth = req.OriginalWidth; project.OriginalHeight = req.OriginalHeight;
|
|
project.Aspect = req.Aspect; project.ProjectDurationSec = req.ProjectDurationSec;
|
|
project.MinDurationSec = req.MinDurationSec; project.MaxDurationSec = req.MaxDurationSec;
|
|
project.FreeFps = req.FreeFps; project.ChooseMode = chooseMode; project.Resolution = resolution;
|
|
project.VipFactor = req.VipFactor; project.RenderAepComp = req.RenderAepComp;
|
|
project.SharedLayerImage = req.SharedLayerImage; project.SharedColorsSvg = req.SharedColorsSvg;
|
|
project.SharedColorPresetsSvg = req.SharedColorPresetsSvg;
|
|
project.IsPublished = req.IsPublished; project.Sort = req.Sort;
|
|
project.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await db.SaveChangesAsync();
|
|
return await GetProjectDetailAsync(project.Id);
|
|
}
|
|
|
|
/// <summary>Partial update — only applies supplied (non-null) fields, so editing an
|
|
/// aspect/resolution never clears render/colour data the full update would require.</summary>
|
|
public async Task<ProjectDetailResponse> PatchProjectAsync(Guid id, PatchProjectRequest req)
|
|
{
|
|
var project = await db.Projects.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
|
|
if (req.Name != null) project.Name = req.Name;
|
|
if (req.Description != null) project.Description = req.Description;
|
|
if (req.Aspect != null) project.Aspect = req.Aspect;
|
|
if (req.Resolution != null)
|
|
{
|
|
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
|
|
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
|
|
project.Resolution = resolution;
|
|
}
|
|
if (req.ChooseMode != null)
|
|
{
|
|
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
|
|
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
|
|
project.ChooseMode = chooseMode;
|
|
}
|
|
if (req.OriginalWidth.HasValue) project.OriginalWidth = req.OriginalWidth.Value;
|
|
if (req.OriginalHeight.HasValue) project.OriginalHeight = req.OriginalHeight.Value;
|
|
if (req.ProjectDurationSec.HasValue) project.ProjectDurationSec = req.ProjectDurationSec.Value;
|
|
if (req.MinDurationSec.HasValue) project.MinDurationSec = req.MinDurationSec;
|
|
if (req.MaxDurationSec.HasValue) project.MaxDurationSec = req.MaxDurationSec;
|
|
if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value;
|
|
if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value;
|
|
if (req.Sort.HasValue) project.Sort = req.Sort.Value;
|
|
// media/colour fields (set by the assets-bundle ingestion)
|
|
if (req.Image != null) project.Image = req.Image;
|
|
if (req.FullDemo != null) project.FullDemo = req.FullDemo;
|
|
if (req.SharedColorsSvg != null) project.SharedColorsSvg = req.SharedColorsSvg;
|
|
project.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await db.SaveChangesAsync();
|
|
return await GetProjectDetailAsync(project.Id);
|
|
}
|
|
|
|
public async Task DeleteProjectAsync(Guid id)
|
|
{
|
|
var project = await db.Projects.FindAsync(id)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
project.DeletedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task IncrementContainerViewAsync(Guid id)
|
|
{
|
|
await db.ProjectContainers
|
|
.Where(x => x.Id == id)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ViewCount, x => x.ViewCount + 1));
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private async Task SyncContainerCategoriesAsync(Guid containerId, List<Guid> categoryIds)
|
|
{
|
|
var existing = await db.ContainerCategories.Where(x => x.ContainerId == containerId).ToListAsync();
|
|
db.ContainerCategories.RemoveRange(existing);
|
|
for (int i = 0; i < categoryIds.Count; i++)
|
|
db.ContainerCategories.Add(new ContainerCategory { ContainerId = containerId, CategoryId = categoryIds[i], Sort = i });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task SyncContainerTagsAsync(Guid containerId, List<Guid> tagIds)
|
|
{
|
|
var existing = await db.ContainerTags.Where(x => x.ContainerId == containerId).ToListAsync();
|
|
db.ContainerTags.RemoveRange(existing);
|
|
foreach (var tagId in tagIds)
|
|
db.ContainerTags.Add(new ContainerTag { ContainerId = containerId, TagId = tagId });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static ContainerSummaryResponse MapContainerSummary(ProjectContainer c) => new(
|
|
c.Id, c.Slug, c.Name, c.Description, c.Image, c.Demo, c.MiniDemo,
|
|
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
|
|
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
|
|
c.ContainerCategories.Select(cc => cc.Category.Slug).ToList(),
|
|
c.ContainerTags.Select(ct => ct.Tag.Name).ToList()
|
|
);
|
|
|
|
private static ContainerDetailResponse MapContainerDetail(ProjectContainer c) => new(
|
|
c.Id, c.Slug, c.Name, c.Description, c.Keywords, c.NewsText,
|
|
c.Image, c.Demo, c.FullDemo, c.MiniDemo, c.DemoScriptTag,
|
|
c.IsPublished, c.IsPremium, c.IsMockup, c.PrimaryMode.ToString(),
|
|
c.RateAvg, c.RateCount, c.ViewCount, c.UseCount, c.Sort, c.SortDate,
|
|
c.Projects.Select(MapProject).ToList(),
|
|
c.ContainerCategories.Select(cc => MapCategoryFlat(cc.Category)).ToList(),
|
|
c.ContainerTags.Select(ct => new TagResponse(ct.Tag.Id, ct.Tag.Name, ct.Tag.LatinName, ct.Tag.Slug, ct.Tag.AppliesToMode, ct.Tag.IsActive)).ToList()
|
|
);
|
|
|
|
private static CategoryResponse MapCategoryFlat(Category c) => new(
|
|
c.Id, c.ParentId, c.Name, c.Slug, c.Description, c.ImageUrl, c.Icon,
|
|
c.IsActive, c.Sort, []
|
|
);
|
|
|
|
private static ProjectResponse MapProject(Project p) => new(
|
|
p.Id, p.ContainerId, p.Name, p.Image, p.FullDemo,
|
|
p.OriginalWidth, p.OriginalHeight, p.Aspect,
|
|
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
|
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(),
|
|
p.IsPublished, p.Sort,
|
|
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp
|
|
);
|
|
|
|
/// <summary>Browse/search all projects (template items) across containers.</summary>
|
|
public async Task<PagedResponse<ProjectListItemResponse>> ListProjectsAsync(string? q, Guid? containerId, int page, int pageSize)
|
|
{
|
|
if (page < 1) page = 1;
|
|
if (pageSize < 1 || pageSize > 200) pageSize = 30;
|
|
var query = db.Projects.Where(p => p.DeletedAt == null);
|
|
if (containerId.HasValue) query = query.Where(p => p.ContainerId == containerId.Value);
|
|
if (!string.IsNullOrWhiteSpace(q)) query = query.Where(p => EF.Functions.ILike(p.Name, $"%{q}%"));
|
|
var total = await query.LongCountAsync();
|
|
var items = await query
|
|
.OrderByDescending(p => p.UpdatedAt)
|
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
|
.Select(p => new ProjectListItemResponse(
|
|
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
|
|
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort))
|
|
.ToListAsync();
|
|
return new PagedResponse<ProjectListItemResponse>(items,
|
|
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
|
|
}
|
|
|
|
public async Task<List<ProjectAssetResponse>> ListAssetsAsync(Guid projectId) =>
|
|
await db.ProjectAssets.Where(a => a.ProjectId == projectId).OrderBy(a => a.Sort)
|
|
.Select(a => new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort))
|
|
.ToListAsync();
|
|
|
|
public async Task<ProjectAssetResponse> AddAssetAsync(Guid projectId, CreateAssetRequest req)
|
|
{
|
|
var a = new ProjectAsset
|
|
{
|
|
ProjectId = projectId, Name = req.Name, Kind = string.IsNullOrWhiteSpace(req.Kind) ? "footage" : req.Kind,
|
|
Url = req.Url, MinioKey = req.MinioKey, SizeBytes = req.SizeBytes, Sort = req.Sort,
|
|
};
|
|
db.ProjectAssets.Add(a);
|
|
await db.SaveChangesAsync();
|
|
return new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort);
|
|
}
|
|
|
|
public async Task DeleteAssetAsync(Guid assetId)
|
|
{
|
|
var a = await db.ProjectAssets.FindAsync(assetId);
|
|
if (a != null) { db.ProjectAssets.Remove(a); await db.SaveChangesAsync(); }
|
|
}
|
|
|
|
/// <summary>Attach an uploaded After Effects file (and render composition) to a project.</summary>
|
|
public async Task<ProjectDetailResponse> SetProjectAepAsync(Guid id, SetAepRequest req)
|
|
{
|
|
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null)
|
|
?? throw new KeyNotFoundException($"Project {id} not found");
|
|
if (req.AepFileUrl != null) project.AepFileUrl = req.AepFileUrl;
|
|
if (req.AepMinioBucket != null) project.AepMinioBucket = req.AepMinioBucket;
|
|
if (req.AepMinioKey != null) project.AepMinioKey = req.AepMinioKey;
|
|
if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5;
|
|
if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes;
|
|
if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp;
|
|
if (req.Folder != null) project.Folder = req.Folder;
|
|
project.AepUploadedAt = DateTime.UtcNow;
|
|
project.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return await GetProjectDetailAsync(id);
|
|
}
|
|
|
|
private static ProjectDetailResponse MapProjectDetail(Project p) => new(
|
|
p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink,
|
|
p.OriginalWidth, p.OriginalHeight, p.Aspect,
|
|
p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec,
|
|
p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), p.VipFactor, p.RenderAepComp,
|
|
p.SharedLayerImage, p.IsPublished, p.Sort,
|
|
p.Scenes.Select(MapScene).ToList(),
|
|
p.SharedColors.Select(sc => new SharedColorResponse(sc.Id, sc.ElementKey, sc.Title, sc.Icon, sc.AttrValue.ToString(), sc.DefaultColor, sc.Sort)).ToList(),
|
|
p.SharedLayers.Select(MapSharedLayer).ToList()
|
|
);
|
|
|
|
private static SceneResponse MapScene(Scene s) => new(
|
|
s.Id, s.ProjectId, s.Key, s.Title, s.LocalizedTitle, s.SceneType.ToString(),
|
|
s.Image, s.Demo, s.SnapshotUrl, s.GenerateKf,
|
|
s.DefaultDurationSec, s.MinDurationSec, s.MaxDurationSec, s.OverlapAtEndSec,
|
|
s.CanHandleDuration, s.ManualColorSelection, s.Sort, s.IsActive
|
|
);
|
|
|
|
private static SharedLayerResponse MapSharedLayer(SharedLayer sl) => new(
|
|
sl.Id, sl.Key, sl.Title, sl.LocalizedTitle, sl.Hint, sl.Type.ToString(), sl.DefaultValue,
|
|
sl.FontId, sl.FontFace, sl.FontSize, sl.IsFontChangeable, sl.IsFontSizeChangeable,
|
|
sl.Justify.ToString(), sl.CanJustify, sl.PositionInContainer, sl.IsTextBox, sl.MaxSize,
|
|
sl.VideoSupport, sl.MinDurationSec, sl.MaxDurationSec, sl.Width, sl.Height,
|
|
sl.MappedList, sl.AiInputType.ToString(), sl.IsHidden, sl.IsFocused,
|
|
sl.VirtualCount, sl.Sort
|
|
);
|
|
}
|