initial commit
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user