using lxc_songlist.Data; using lxc_songlist.Models; using Microsoft.EntityFrameworkCore; namespace lxc_songlist.Services; public sealed class SongCatalogService(IDbContextFactory dbFactory) { public async Task> 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> 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> 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> 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()); } }