Files
McSchneidarandFlorian Ostertag 336d08040e New Webserver and WebApp (#942)
* Replace the WCF webserver with an ASP.NET Core webserver (VocaluxeWeb)

- New project VocaluxeWeb: ASP.NET Core 2.3 (Kestrel) on .NET Framework 4.8.1, HTTP or HTTPS
  with a self-signed certificate, REST API (/api/v1) with Swagger, live events (Server-Sent Events)
- Access to the game only through IVocaluxeRemote; requests run on the main thread once per frame
- Sessions with roles (guest, player, admin) and configurable permissions, PINs hashed with PBKDF2
- Queue as a playlist with requesters and singers, fair order (one song per person and round)
- Admin: settings, server status, logged-in devices, activate/deactivate and delete profiles
- Profile pictures: own photo or one of the Vocaluxe avatars
- Game: WebApp queue screen (song options, playlist group) to start the next song with the chosen
  singers, return to it after singing, warning when the webserver is off, remote keyboard
- Old WCF server, WebserverInitalConfig and URL reservations removed; libraries moved to libs/managed
- Unit tests for PIN hashing, settings and permissions, queue order and device names
- AppVeyor: Visual Studio 2022 image and Node.js 24 (the build compiles the WebApp)
- Documentation in VocaluxeWeb/README.md

* New WebApp (Vue 3, TypeScript, Vite) replacing the old jQuery Mobile website

- Login with profile and PIN, create profiles
- Songs with search, filters, sorting and 30 second previews
- Queue: add songs with game mode, audio and singers, edit, reorder, start in the game
- Playlists: create, edit, add to the queue
- Now playing with live updates, remote control and keyboard
- Players: own profile with picture (photo or Vocaluxe avatar), roles for admins
- Admin settings: permissions, server status, devices, profiles
- Help page, responsive layout for phones, tablets and desktops, light and dark mode
- Texts in WebApp/src/locales/en.json, translated with Transifex
- Old website and PhoneGap config removed

---------

Co-authored-by: Florian Ostertag <flokuep@users.noreply.github.com>
2026-10-26 05:40:11 +01:00

417 lines
15 KiB
C#

#region license
// This file is part of Vocaluxe.
//
// Vocaluxe is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Vocaluxe is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Vocaluxe. If not, see <http://www.gnu.org/licenses/>.
#endregion
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VocaluxeWeb.Contracts;
using VocaluxeWeb.Dispatching;
using VocaluxeWeb.Remote;
namespace VocaluxeWeb.Services
{
/// The queue is a normal playlist of the game (name from the settings), so it can also be used in the song screen.
/// Game mode and audio mode are stored in the playlist; who wished a song and who should sing it is only known to the webserver.
/// New wishes are inserted fairly: everybody gets one song per round.
/// Every change is pushed to all WebApps as live event "queue".
public sealed class CQueueService
{
private sealed class CMeta
{
public string Id;
public int SongId;
public ProfileRefDto RequestedBy;
public List<ProfileRefDto> Players = new List<ProfileRefDto>();
}
private sealed class CItem
{
public RemotePlaylistEntry Entry;
public CMeta Meta;
}
public sealed class CForbiddenException : Exception { }
private readonly IVocaluxeRemote _Remote;
private readonly CMainThreadDispatcher _Dispatcher;
private readonly CWebSettingsStore _Settings;
private readonly CSongLibrary _Songs;
private readonly CLiveEvents _Events;
private readonly object _Lock = new object();
private List<CMeta> _Metas = new List<CMeta>();
private string _Fingerprint;
private int? _StartedSongId;
public CQueueService(IVocaluxeRemote remote, CMainThreadDispatcher dispatcher, CWebSettingsStore settings, CSongLibrary songs, CLiveEvents events)
{
_Remote = remote;
_Dispatcher = dispatcher;
_Settings = settings;
_Songs = songs;
_Events = events;
}
public async Task<IList<QueueEntryDto>> GetAsync()
{
return await _ToDtosAsync((await _ChangeAsync(items => false)).Items);
}
public Task<IList<QueueEntryDto>> AddAsync(int songId, ESongGameMode gameMode, ESongAudioMode audioMode, List<ProfileRefDto> players, CSession session)
{
return _ChangeAndPublishAsync(items =>
{
var position = _FairPosition(items, session.ProfileId);
items.Insert(position, new CItem
{
Entry = new RemotePlaylistEntry { SongId = songId, GameMode = (int)gameMode, AudioMode = (int)audioMode },
Meta = new CMeta
{
Id = _NewId(),
SongId = songId,
RequestedBy = new ProfileRefDto { Id = session.ProfileId, Name = session.ProfileName },
Players = players ?? new List<ProfileRefDto>()
}
});
return true;
});
}
/// Adds several songs (e.g. from a playlist) for one person; each one is placed fairly.
public Task<IList<QueueEntryDto>> AddManyAsync(IList<RemotePlaylistEntry> songs, CSession session)
{
return _ChangeAndPublishAsync(items =>
{
foreach (var song in songs)
{
var position = _FairPosition(items, session.ProfileId);
items.Insert(position, new CItem
{
Entry = new RemotePlaylistEntry { SongId = song.SongId, GameMode = song.GameMode, AudioMode = song.AudioMode },
Meta = new CMeta
{
Id = _NewId(),
SongId = song.SongId,
RequestedBy = new ProfileRefDto { Id = session.ProfileId, Name = session.ProfileName }
}
});
}
return songs.Count > 0;
});
}
public Task<IList<QueueEntryDto>> UpdateAsync(string id, ESongGameMode? gameMode, ESongAudioMode? audioMode, List<ProfileRefDto> players, CSession session, bool canManage)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
_CheckOwner(item, session, canManage);
if (gameMode.HasValue)
{
item.Entry.GameMode = (int)gameMode.Value;
}
if (audioMode.HasValue)
{
item.Entry.AudioMode = (int)audioMode.Value;
}
if (players != null)
{
item.Meta.Players = players;
}
return true;
});
}
public Task<IList<QueueEntryDto>> RemoveAsync(string id, CSession session, bool canManage)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
_CheckOwner(item, session, canManage);
items.Remove(item);
return true;
});
}
public Task<IList<QueueEntryDto>> MoveAsync(string id, int newPosition)
{
return _ChangeAndPublishAsync(items =>
{
var item = _Find(items, id);
items.Remove(item);
items.Insert(Math.Max(0, Math.Min(newPosition, items.Count)), item);
return true;
});
}
public Task<IList<QueueEntryDto>> ClearAsync()
{
return _ChangeAndPublishAsync(items =>
{
var changed = items.Count > 0;
items.Clear();
return changed;
});
}
/// A song started in the game: its entry leaves the queue.
/// Skipped if the entry was already removed because it was started from the queue (see <see cref="MarkStarted" />).
public Task<IList<QueueEntryDto>> RemoveSungAsync(int songId)
{
return _ChangeAndPublishAsync(items =>
{
lock (_Lock)
{
if (_StartedSongId == songId)
{
_StartedSongId = null;
return false;
}
}
var item = items.FirstOrDefault(i => i.Entry.SongId == songId);
return item != null && items.Remove(item);
});
}
/// Creates the queue playlist if it does not exist yet, so it is available in the game right away. Main thread only.
public void EnsurePlaylist()
{
var name = _Settings.Current.QueuePlaylistName;
if (_Remote.GetPlaylists().All(p => p.Name != name))
{
_Remote.CreatePlaylist(name);
}
}
/// The queue with requesters and singers, for the queue screen of the game. Main thread only.
public IList<RemoteQueueItem> GetGameSnapshot()
{
var name = _Settings.Current.QueuePlaylistName;
var entries = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name)?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
var items = _Match(entries);
_Metas = items.Select(i => i.Meta).ToList();
return items.Select(i => new RemoteQueueItem
{
SongId = i.Entry.SongId,
GameMode = i.Entry.GameMode,
AudioMode = i.Entry.AudioMode,
RequestedBy = i.Meta.RequestedBy?.Name,
PlayerIds = i.Meta.Players.Select(p => p.Id).ToList(),
PlayerNames = i.Meta.Players.Select(p => p.Name).ToList()
}).ToList();
}
}
/// Position of the entry in the queue, -1 if it is gone. Main thread only.
public int IndexOf(string id)
{
var name = _Settings.Current.QueuePlaylistName;
var entries = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name)?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
return _Match(entries).FindIndex(i => i.Meta.Id == id);
}
}
/// The entry at this position was started: it leaves the queue right away. Main thread only.
public void MarkStarted(int index)
{
var name = _Settings.Current.QueuePlaylistName;
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
if (playlist == null)
{
return;
}
lock (_Lock)
{
var items = _Match(playlist.Entries);
if (index < 0 || index >= items.Count)
{
return;
}
_StartedSongId = items[index].Entry.SongId;
items.RemoveAt(index);
_Remote.SetPlaylistEntries(playlist.Id, items.Select(i => i.Entry).ToList());
_Metas = items.Select(i => i.Meta).ToList();
_Fingerprint = _GetFingerprint(items.Select(i => i.Entry));
}
Task.Run(PublishAsync);
}
/// Called regularly on the main thread: true if the queue playlist was changed in the game.
public bool HasExternalChange()
{
var name = _Settings.Current.QueuePlaylistName;
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
var fingerprint = _GetFingerprint(playlist?.Entries ?? new List<RemotePlaylistEntry>());
lock (_Lock)
{
return _Fingerprint != null && fingerprint != _Fingerprint;
}
}
/// Sends the current queue to all WebApps.
public async Task PublishAsync()
{
_Events.Publish("queue", await GetAsync());
}
private async Task<IList<QueueEntryDto>> _ChangeAndPublishAsync(Func<List<CItem>, bool> change)
{
var (items, changed) = await _ChangeAsync(change);
var dtos = await _ToDtosAsync(items);
if (changed)
{
_Events.Publish("queue", dtos);
}
return dtos;
}
/// Reads the queue playlist and applies a change in one go on the main thread, so the game cannot interfere.
private Task<(List<CItem> Items, bool Changed)> _ChangeAsync(Func<List<CItem>, bool> change)
{
var name = _Settings.Current.QueuePlaylistName;
return _Dispatcher.InvokeAsync(() =>
{
var playlist = _Remote.GetPlaylists().FirstOrDefault(p => p.Name == name);
var entries = playlist?.Entries ?? new List<RemotePlaylistEntry>();
lock (_Lock)
{
var items = _Match(entries);
var changed = change(items);
if (changed)
{
var playlistId = playlist?.Id ?? _Remote.CreatePlaylist(name);
_Remote.SetPlaylistEntries(playlistId, items.Select(i => i.Entry).ToList());
}
_Metas = items.Select(i => i.Meta).ToList();
_Fingerprint = _GetFingerprint(items.Select(i => i.Entry));
return (items, changed);
}
});
}
/// Assigns the known metadata to the current playlist entries, which may have been changed in the game.
private List<CItem> _Match(IList<RemotePlaylistEntry> entries)
{
var result = new List<CItem>(entries.Count);
var next = 0;
foreach (var entry in entries)
{
var found = _Metas.FindIndex(next, m => m.SongId == entry.SongId);
CMeta meta;
if (found >= 0)
{
meta = _Metas[found];
next = found + 1;
}
else
{
meta = new CMeta { Id = _NewId(), SongId = entry.SongId };
}
result.Add(new CItem { Entry = new RemotePlaylistEntry { SongId = entry.SongId, GameMode = entry.GameMode, AudioMode = entry.AudioMode }, Meta = meta });
}
return result;
}
private static CItem _Find(List<CItem> items, string id)
{
return items.FirstOrDefault(i => i.Meta.Id == id) ?? throw new KeyNotFoundException("Queue entry not found");
}
private static void _CheckOwner(CItem item, CSession session, bool canManage)
{
if (!canManage && item.Meta.RequestedBy?.Id != session.ProfileId)
{
throw new CForbiddenException();
}
}
/// The n-th wish of a person goes behind everybody's n-th wish.
/// Entries without requester count as a wish of their own.
private static int _FairPosition(IList<CItem> items, Guid profileId)
{
var round = items.Count(i => i.Meta.RequestedBy?.Id == profileId);
var seen = new Dictionary<Guid, int>();
var position = 0;
for (var i = 0; i < items.Count; i++)
{
var entryRound = 0;
var requester = items[i].Meta.RequestedBy;
if (requester != null)
{
seen.TryGetValue(requester.Id, out entryRound);
seen[requester.Id] = entryRound + 1;
}
if (entryRound <= round)
{
position = i + 1;
}
}
return position;
}
private static string _GetFingerprint(IEnumerable<RemotePlaylistEntry> entries)
{
return string.Join(",", entries.Select(e => e.SongId + ":" + e.GameMode + ":" + e.AudioMode));
}
private static string _NewId()
{
return Guid.NewGuid().ToString("N").Substring(0, 12);
}
private async Task<IList<QueueEntryDto>> _ToDtosAsync(List<CItem> items)
{
var result = new List<QueueEntryDto>(items.Count);
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
result.Add(new QueueEntryDto
{
Id = item.Meta.Id,
Position = i,
Song = await _Songs.GetDtoAsync(item.Entry.SongId),
RequestedBy = item.Meta.RequestedBy,
GameMode = (ESongGameMode)item.Entry.GameMode,
AudioMode = (ESongAudioMode)item.Entry.AudioMode,
Players = item.Meta.Players
});
}
return result;
}
}
}