Files
lxc-songlist/Services/SongCatalogService.cs
T
2026-06-03 20:24:57 -07:00

300 lines
11 KiB
C#

using lxc_songlist.Data;
using lxc_songlist.Models;
using Microsoft.EntityFrameworkCore;
namespace lxc_songlist.Services;
public sealed class SongCatalogService(IDbContextFactory<SongListDbContext> dbFactory)
{
public async Task<IReadOnlyList<SongListItem>> SearchSongsAsync(
string? search,
string? selectedTag,
CancellationToken cancellationToken = default)
{
var queryText = TextNormalizer.Key(search ?? string.Empty);
var tagKey = TextNormalizer.Key(selectedTag ?? string.Empty);
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var query = db.Songs
.AsNoTracking()
.Include(song => song.SongTags)
.ThenInclude(songTag => songTag.Tag)
.Where(song => !song.IsHidden);
if (!string.IsNullOrWhiteSpace(queryText))
{
query = query.Where(song =>
song.NormalizedTitle.Contains(queryText) ||
song.SongTags.Any(songTag => songTag.Tag.NormalizedName.Contains(queryText)));
}
if (!string.IsNullOrWhiteSpace(tagKey))
{
query = query.Where(song => song.SongTags.Any(songTag => songTag.Tag.NormalizedName == tagKey));
}
var songs = await query
.OrderBy(song => song.NormalizedTitle)
.ToListAsync(cancellationToken);
return songs.Select(ToSongListItem).ToList();
}
public async Task<IReadOnlyList<string>> GetVisibleTagsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Tags
.AsNoTracking()
.Where(tag => tag.SongTags.Any(songTag => !songTag.Song.IsHidden))
.OrderBy(tag => tag.NormalizedName)
.Select(tag => tag.Name)
.ToListAsync(cancellationToken);
}
public async Task AddViewerTagAsync(int songId, string rawTag, CancellationToken cancellationToken = default)
{
var displayTag = TextNormalizer.DisplayText(rawTag);
var tagKey = TextNormalizer.Key(rawTag);
if (displayTag.Length is 0 or > 50)
{
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var songExists = await db.Songs.AnyAsync(song => song.Id == songId && !song.IsHidden, cancellationToken);
if (!songExists)
{
throw new InvalidOperationException("歌曲不存在或已隐藏。");
}
var tag = await db.Tags.FirstOrDefaultAsync(existing => existing.NormalizedName == tagKey, cancellationToken);
if (tag is null)
{
tag = new Tag { Name = displayTag, NormalizedName = tagKey, CreatedAt = DateTimeOffset.UtcNow };
db.Tags.Add(tag);
}
var alreadyLinked = await db.SongTags.AnyAsync(
songTag => songTag.SongId == songId && songTag.Tag.NormalizedName == tagKey,
cancellationToken);
if (!alreadyLinked)
{
db.SongTags.Add(new SongTag
{
SongId = songId,
Tag = tag,
CreatedByViewer = true,
CreatedAt = DateTimeOffset.UtcNow
});
}
await db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<AdminSongItem>> GetAdminSongsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var songs = await db.Songs
.AsNoTracking()
.Include(song => song.SongTags)
.ThenInclude(songTag => songTag.Tag)
.OrderBy(song => song.NormalizedTitle)
.ToListAsync(cancellationToken);
return songs
.Select(song => new AdminSongItem(
song.Id,
song.Title,
song.IsHidden,
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList()))
.ToList();
}
public async Task CreateSongAsync(string rawTitle, CancellationToken cancellationToken = default)
{
var title = TextNormalizer.DisplayText(rawTitle);
var normalizedTitle = TextNormalizer.Key(title);
if (title.Length is 0 or > 200)
{
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var exists = await db.Songs.AnyAsync(song => song.NormalizedTitle == normalizedTitle, cancellationToken);
if (exists)
{
throw new InvalidOperationException("这个歌名已经存在。");
}
var now = DateTimeOffset.UtcNow;
db.Songs.Add(new Song
{
Title = title,
NormalizedTitle = normalizedTitle,
CreatedAt = now,
UpdatedAt = now
});
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateSongAsync(int songId, string rawTitle, bool isHidden, CancellationToken cancellationToken = default)
{
var title = TextNormalizer.DisplayText(rawTitle);
var normalizedTitle = TextNormalizer.Key(title);
if (title.Length is 0 or > 200)
{
throw new InvalidOperationException("歌名长度需要在 1 到 200 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var song = await db.Songs.FindAsync([songId], cancellationToken)
?? throw new InvalidOperationException("歌曲不存在。");
var duplicate = await db.Songs.AnyAsync(
existing => existing.Id != songId && existing.NormalizedTitle == normalizedTitle,
cancellationToken);
if (duplicate)
{
throw new InvalidOperationException("这个歌名已经存在。");
}
song.Title = title;
song.NormalizedTitle = normalizedTitle;
song.IsHidden = isHidden;
song.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
public async Task DeleteSongAsync(int songId, CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var song = await db.Songs.FindAsync([songId], cancellationToken);
if (song is null)
{
return;
}
db.Songs.Remove(song);
await db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<AdminTagItem>> GetAdminTagsAsync(CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
return await db.Tags
.AsNoTracking()
.OrderBy(tag => tag.NormalizedName)
.Select(tag => new AdminTagItem(tag.Id, tag.Name, tag.SongTags.Count))
.ToListAsync(cancellationToken);
}
public async Task RenameTagAsync(int tagId, string rawName, CancellationToken cancellationToken = default)
{
var displayName = TextNormalizer.DisplayText(rawName);
var tagKey = TextNormalizer.Key(rawName);
if (displayName.Length is 0 or > 50)
{
throw new InvalidOperationException("标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var tag = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(existing => existing.Id == tagId, cancellationToken)
?? throw new InvalidOperationException("标签不存在。");
var target = await db.Tags.Include(existing => existing.SongTags).FirstOrDefaultAsync(
existing => existing.Id != tagId && existing.NormalizedName == tagKey,
cancellationToken);
if (target is not null)
{
await MoveLinksAndDeleteTagAsync(db, tag, target, cancellationToken);
return;
}
tag.Name = displayName;
tag.NormalizedName = tagKey;
await db.SaveChangesAsync(cancellationToken);
}
public async Task DeleteTagAsync(int tagId, CancellationToken cancellationToken = default)
{
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var tag = await db.Tags.FindAsync([tagId], cancellationToken);
if (tag is null)
{
return;
}
db.Tags.Remove(tag);
await db.SaveChangesAsync(cancellationToken);
}
public async Task MergeTagAsync(int sourceTagId, string rawTargetName, CancellationToken cancellationToken = default)
{
var targetName = TextNormalizer.DisplayText(rawTargetName);
var targetKey = TextNormalizer.Key(rawTargetName);
if (targetName.Length is 0 or > 50)
{
throw new InvalidOperationException("目标标签长度需要在 1 到 50 个字符之间。");
}
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
var source = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.Id == sourceTagId, cancellationToken)
?? throw new InvalidOperationException("来源标签不存在。");
var target = await db.Tags.Include(tag => tag.SongTags).FirstOrDefaultAsync(tag => tag.NormalizedName == targetKey, cancellationToken);
if (target is null)
{
target = new Tag { Name = targetName, NormalizedName = targetKey, CreatedAt = DateTimeOffset.UtcNow };
db.Tags.Add(target);
await db.SaveChangesAsync(cancellationToken);
}
if (source.Id == target.Id)
{
return;
}
await MoveLinksAndDeleteTagAsync(db, source, target, cancellationToken);
}
private static async Task MoveLinksAndDeleteTagAsync(
SongListDbContext db,
Tag source,
Tag target,
CancellationToken cancellationToken)
{
var links = await db.SongTags.Where(link => link.TagId == source.Id).ToListAsync(cancellationToken);
foreach (var link in links)
{
var targetLinkExists = await db.SongTags.AnyAsync(
existing => existing.SongId == link.SongId && existing.TagId == target.Id,
cancellationToken);
if (!targetLinkExists)
{
db.SongTags.Add(new SongTag
{
SongId = link.SongId,
TagId = target.Id,
CreatedByViewer = link.CreatedByViewer,
CreatedAt = link.CreatedAt
});
}
}
db.Tags.Remove(source);
await db.SaveChangesAsync(cancellationToken);
}
private static SongListItem ToSongListItem(Song song)
{
return new SongListItem(
song.Id,
song.Title,
song.SongTags.Select(songTag => songTag.Tag.Name).OrderBy(tag => tag).ToList());
}
}