Files
flatrender/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs
T
soroush.asadi a36e96d933 fix(templates): real scene count on template pages (was always 0)
The card + detail read template.sceneCount, but the API never sent one — so the
frontend mapper hardcoded sceneCount:0 for every DB-backed template.

- content-svc: ContainerSummaryResponse + ContainerDetailResponse now carry
  SceneCount. The list computes it with one grouped query (scenes per aspect project,
  max across aspects); the detail loads scenes and counts them.
- frontend: V2ContainerSummary.scene_count → AdminProject.sceneCount → the catalog
  card/detail (adminProjectToCatalogTemplate no longer hardcodes 0).

Verified on the live local API: fr-instagram-promo → 5, single-scene templates → 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:51:12 +03:30

590 lines
31 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();
// Scene count per template = scenes in a single aspect project (max across
// projects). One grouped query, no scene entities loaded.
var ids = items.Select(i => i.Id).ToList();
var sceneCounts = (await db.Projects
.Where(p => p.DeletedAt == null && ids.Contains(p.ContainerId))
.Select(p => new { p.ContainerId, Cnt = p.Scenes.Count(s => s.DeletedAt == null) })
.ToListAsync())
.GroupBy(x => x.ContainerId)
.ToDictionary(g => g.Key, g => g.Max(x => x.Cnt));
return new PagedResponse<ContainerSummaryResponse>(
items.Select(c => MapContainerSummary(c, sceneCounts.GetValueOrDefault(c.Id, 0))),
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)).ThenInclude(p => p.Scenes.Where(s => s.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)).ThenInclude(p => p.Scenes.Where(s => s.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,
RenderEngine = string.IsNullOrWhiteSpace(req.RenderEngine) ? "AfterEffects" : req.RenderEngine,
RenderRemotionComp = req.RenderRemotionComp,
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,
RenderEngine = src.RenderEngine, RenderRemotionComp = src.RenderRemotionComp,
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;
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
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;
if (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
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, int sceneCount = 0) => 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(),
sceneCount
);
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(),
c.Projects.Count == 0 ? 0 : c.Projects.Max(p => p.Scenes.Count)
);
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,
p.RenderEngine, p.RenderRemotionComp
);
/// <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,
p.RenderEngine, p.RenderRemotionComp))
.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 (!string.IsNullOrWhiteSpace(req.RenderEngine)) project.RenderEngine = req.RenderEngine;
if (req.RenderRemotionComp != null) project.RenderRemotionComp = req.RenderRemotionComp;
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.RenderEngine, p.RenderRemotionComp,
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
);
}