Author SHA1 Message Date
Simon Diesenreiter de01023fcc implement remote storage capabilities 2026-11-08 14:49:32 +01:00
Simon DiesenreiterandClaude Opus 5.5 e58730cf62 AGENTS.md: slow first start with GStreamer and Smart App Control
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:47:04 +01:00
Simon DiesenreiterandClaude Opus 5.5 5c55f74eb3 AGENTS.md: how to run the game without the C++ build tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:16:56 +01:00
Simon DiesenreiterandClaude Opus 5.5 ecb2c6d8e7 Revert build-generated copyright year in AssemblyInfo.cs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:16:12 +01:00
Simon DiesenreiterandClaude Opus 5.5 1e65864c8f Song server: search and download songs, mobile web UI
- Sources: "free" (Creative Commons songs with media from
  github.com/UltraStar-Deluxe/songs) and "usdb" (usdb.animux.de with an
  account; audio/video via yt-dlp from the song's #VIDEO hints or YouTube
  links in the comments, covers from fanart.tv/YouTube)
- Aggregated search (all sources in parallel, in-library flag), download
  queue into <library>/Downloads/<source>/<Artist - Title>, then rescan
- Web UI in wwwroot (plain JS, no build): search, downloads with progress,
  library; phone first, dark mode, installable
- Docker image with yt-dlp, ffmpeg and deno; library mounted writable
- Tests for matching, USDB parsing and song rewriting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:15:48 +01:00
Simon DiesenreiterandClaude Opus 5.5 efcc63a640 Song server: central song library with on-demand downloads
- VocaluxeSongServer: ASP.NET Core server that scans an UltraStar library and
  serves the song list (ETag), single files (Range) and zip bundles; optional
  API key; Dockerfile and Gitea workflow for git.disi.dev/projects/vocaluxe-song-server
- Game: config section SongServer (Url, ApiKey). At song loading, the song text
  files are mirrored into a local cache that is loaded like a song folder;
  without connection the last library is used
- VocaluxeLib: CSongFiles hook for all song file access; media are downloaded on
  demand (covers in the background, preview audio, everything before singing
  with progress in the sing screen)
- Tests for scanner, API and downloads (incl. resume); AGENTS.md for the fork

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:23:00 +01:00
54 changed files with 4593 additions and 20 deletions

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# Builds the song server image and pushes it to the Gitea registry:
# git.disi.dev/projects/vocaluxe-song-server:<version> and :latest
# Release: set VocaluxeSongServer/VERSION, then push the tag songserver-v<version> (e.g. songserver-v0.1.0).
# Manual runs (workflow_dispatch) push :dev only.
name: Song server image
env:
DOCKER_API_VERSION: '1.43'
IMAGE: git.disi.dev/projects/vocaluxe-song-server
on:
push:
tags:
- 'songserver-v*'
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Determine tags
id: tags
run: |
VERSION=$(tr -d '[:space:]' < VocaluxeSongServer/VERSION)
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
if [ "${GITHUB_REF_NAME}" != "songserver-v${VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match VocaluxeSongServer/VERSION (${VERSION})"
exit 1
fi
echo "tags=${VERSION} latest" >> "$GITHUB_OUTPUT"
else
echo "tags=dev" >> "$GITHUB_OUTPUT"
fi
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
username: gitearobot
password: ${{ secrets.PACKAGE_GITEA_PAT }}
registry: git.disi.dev
- name: Build and publish
run: |
docker build -t "${IMAGE}:build" -f VocaluxeSongServer/Dockerfile .
for TAG in ${{ steps.tags.outputs.tags }}; do
docker tag "${IMAGE}:build" "${IMAGE}:${TAG}"
docker push "${IMAGE}:${TAG}"
done
+61
View File
@@ -0,0 +1,61 @@
# Vocaluxe (fork at git.disi.dev/Projects/Vocaluxe)
Fork of [Vocaluxe/Vocaluxe](https://github.com/Vocaluxe/Vocaluxe), a karaoke game (C#, .NET 10). Default branch
`develop` tracks upstream; the fork's own work happens on feature branches. Keep changes to upstream code small and
hook-based, so upstream can still be merged.
## Goal of the fork
1. **Song server** (`VocaluxeSongServer/`, in progress): a central song library on the homelab. The game becomes the
frontend and downloads song files on demand. See `VocaluxeSongServer/README.md`.
2. Later: serve the guest WebApp ("browse by phone", `WebApp/` + `VocaluxeWeb/`) directly from the server.
3. Search and download songs that aren't in the library (started: server web UI with the sources `free` and `usdb`;
next: more sources, UltraSinger to create songs from audio).
## Layout
| Folder | What |
|---|---|
| `Vocaluxe/` | The game. Song server client: `Base/SongServer/` (`CSongServer` sync, `CRemoteSongFileProvider` downloads) |
| `VocaluxeLib/` | Shared library. `Songs/CSongFiles.cs` is the hook every song file access goes through |
| `VocaluxeSongServer/` | ASP.NET Core server, Docker image `git.disi.dev/projects/vocaluxe-song-server`. `Sources/`: song search and downloads (free CC songs from GitHub, USDB + yt-dlp). `wwwroot/`: mobile web UI (plain JS, no build) |
| `VocaluxeWeb/`, `WebApp/` | Built-in webserver of the game and the guest WebApp (Vue), upstream |
| `Tests/` | NUnit; song server tests in `Tests/SongServer` |
## How the song server integration works
- `CSongs.LoadSongs` calls `CSongServer.Sync()`: fetches `/api/v1/library` (ETag), mirrors the song `.txt` files into
`<DataFolder>/SongServer/<host>_<port>` (zip bundles), deletes files no longer on the server, and installs
`CRemoteSongFileProvider` as `CSongFiles.Provider`. The cache folder is then loaded like a normal song folder.
- `CSongFiles.Exists` (loader header checks) is true for files that only exist on the server. `IsLocal` means the file
is downloaded and current (size and mtime match the server).
- Downloads on demand: covers (`CSong.LoadSmallCover`, background threads, blocking), preview audio
(`CBackgroundMusic.LoadPreview` + `Update`), everything before singing (`CScreenSing._LoadAndStartCurrentSong`,
progress via `TR_SCREENSING_DOWNLOADING`). Downloads resume from `.part` files with Range requests.
- Config: `config.xml` section `SongServer` (`Url`, `ApiKey`). The contracts (`VocaluxeSongServer/Contracts`) are
linked into `Vocaluxe.csproj`; the test project references the server with `extern alias songserver`.
## Song server web UI and sources
- The web UI is plain HTML/CSS/JS in `VocaluxeSongServer/wwwroot` (no build step, no external requests except cover
images). Check it at phone width: headless Edge has a ~500px minimum window, so render the page in a 390px iframe.
- USDB can't be tested without an account (search and text download need a login). Its HTML parsing is covered by
`Tests/SongServer/CSongSourcesTest.cs`; if USDB changes its HTML, fix `CUsdbSongSource.ParseList` there.
## Build and test (Windows)
- `dotnet build Vocaluxe/Vocaluxe.csproj -c DebugWin -p:Platform=x64` (the whole solution also builds the native
`PitchTracker`, which needs Visual Studio's C++ tools).
- `dotnet test Tests/Tests.csproj -c DebugWin -p:Platform=x64`
- Running the game (`Output\Vocaluxe.exe`) without the C++ tools: take `libs/unmanaged/x64/PitchTracker.dll` (and the
`Themes/Vocaluxe 2024/*.mp4` videos) from the upstream Nightly zip
(github.com/Vocaluxe/Vocaluxe/releases/tag/Nightly, `Vocaluxe_Nightly_Windows_x64.zip`); the PitchTracker source is
unchanged in this fork. Both are gitignored. Data and `Config.xml` are in `Output` (non-installer builds).
- First start is slow on Windows with Smart App Control: GStreamer (default `PlayBackLib`) scans its 216 plugins once
and each unsigned DLL is checked (~19 min on DisiFramework, window "not responding" in "Init Playback"). Later starts
are fast. `<Sound><PlayBackLib>PortAudio</PlayBackLib></Sound>` in `Config.xml` avoids GStreamer.
- Building rewrites the copyright year in `Vocaluxe/Properties/AssemblyInfo.cs`; don't commit that.
- Don't use `dotnet sln add`: it rewrites the configuration table of `Vocaluxe.sln`. Copy the lines of `VocaluxeWeb`.
- New `config.xml` fields must also go into `Tests/VocaluxeLib/XML/TestFiles/SConfig.xml` (round-trip test; CRLF).
## Known gaps (song server)
- Cover/background auto-detection (`[CO]`/`[BG]` files without a tag, `CSong._CheckFiles`) only sees local files.
- Media in subfolders of a song folder are not served (only the song folder itself).
- On Linux clients, media whose name differs in case from the `.txt` reference won't be found after download.
- No cache size limit yet. The WebApp's cover images for server songs depend on the cover loader having fetched them.
- No in-game UI for the server address yet (only `config.xml`).
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

+1
View File
@@ -267,6 +267,7 @@
<string name="TR_SCREENSING_BUTTON_RESTART_GAME">Restart game</string>
<string name="TR_SCREENSING_VOCALSVOLUME">Vocals track volume</string>
<string name="TR_SCREENSING_NOMORESONGS">No more songs in playlist!</string>
<string name="TR_SCREENSING_DOWNLOADING">Downloading %s ... %p%</string>
<string name="TR_SCREENSING_NOWEBCAM">No webcam available!</string>
<string name="TR_SCREENSING_ASPECTRATIO">Aspect Ratio: %a</string>
<string name="TR_SCREENSING_RATING_1">perfect!</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

@@ -0,0 +1,509 @@
#TITLE:Code Monkey
#ARTIST:Jonathan Coulton
#LANGUAGE:English
#MP3:audio.mp3
#COVER:cover.jpg
#BACKGROUND:background.jpg
#VIDEOGAP:4
#BPM:320
#GAP:675
: 0 6 -4 Code
: 8 3 -4 Mon
: 12 2 -6 key
: 16 3 -6 get
: 21 4 -6 up
: 28 4 -8 get
: 36 3 -8 co
: 40 2 -6 ~f
: 45 5 -11 fee
- 52 53
: 64 6 -4 Code
: 72 3 -4 Mon
: 76 2 -6 key
: 80 3 -6 go
: 84 2 -8 to
: 89 7 -8 job
- 98 108
: 128 5 -4 Code
: 136 3 -4 Mon
: 140 2 -6 key
: 144 5 -6 have
: 152 3 -6 bo
: 156 4 -8 ring
: 164 5 -6 mee
: 172 5 -11 ting
- 179 180
: 188 2 -11 With
: 192 3 -6 bo
: 196 2 -4 ring
: 200 3 -4 ma
: 204 3 -6 na
: 208 2 -8 ger
: 212 6 -8 Rob
- 220 232
: 252 5 -4 Rob
: 260 6 -1 say
: 268 6 -4 Code
: 276 6 -3 Mon
: 284 2 -4 key
: 288 3 -3 ve
: 292 2 -4 ry
: 296 3 -3 dil
: 300 2 -4 li
: 304 5 -4 gent
- 311
: 316 3 -6 But
: 320 2 -4 his
: 324 5 -6 out
: 332 3 -9 put
: 340 5 -8 stink
- 347 360
: 380 5 -4 His
: 388 5 -1 code
: 396 3 -4 not
: 404 6 -3 "func
: 412 3 -4 tio
: 416 3 -3 nal"
: 420 3 -4 or
: 424 3 -3 "e
: 428 2 -4 le
: 432 3 -4 gant"
- 437
: 444 2 -6 What
: 448 2 -4 do
: 452 6 -6 Code
: 460 3 -8 Mon
: 464 2 -9 key
: 468 5 -8 think?
- 475
: 480 6 -8 Code
: 488 2 -8 Mon
: 492 2 -6 key
: 496 4 -4 think
- 502
* 504 6 -3 May
: 512 6 -3 be
: 520 3 -3 ma
: 524 2 -4 na
: 528 4 -4 ger
: 536 3 -4 want
: 540 2 -6 to
: 544 4 -6 write
- 550
: 552 5 -6 God
: 560 5 -1 damned
: 568 3 -1 lo
: 572 4 -3 gin
: 580 10 -4 page
: 592 2 -6 hi
: 596 2 -8 ~m
: 600 4 -8 self
- 606
: 608 3 -9 Co
: 612 2 -8 ~de
: 616 2 -8 Mon
: 620 2 -8 key
: 624 4 -9 not
: 632 3 -9 say
: 637 3 -11 it
- 642 643
: 652 4 -11 Out
: 661 7 -3 loud
- 670
: 672 3 -9 Co
: 676 3 -8 ~de
: 680 3 -8 Mon
: 684 2 -8 key
: 688 3 -8 not
: 696 2 -8 cra
: 699 3 -9 ~
: 704 6 -9 zy
- 712
: 716 4 -13 Just
* 725 6 -3 proud
- 733
: 736 3 -3 Co
: 740 3 -1 ~de
: 744 3 -1 Mon
: 748 2 -3 key
: 752 4 -3 like
: 760 3 -3 Fri
: 764 7 -4 tos
- 773 774
: 800 3 -3 Co
: 804 3 -1 ~de
: 808 3 -1 Mon
: 812 3 -3 key
: 816 4 -3 like
: 824 6 -3 Tab
: 832 3 -4 and
: 836 3 -4 Moun
: 840 2 -6 tain
: 845 6 -4 Dew
- 853 854
: 864 6 -4 Code
: 872 3 -4 Mon
: 876 2 -3 key
: 880 3 -1 ve
: 884 2 -1 ry
: 888 3 -1 sim
: 892 2 -3 ple
: 896 9 -3 man
- 907 908
: 924 2 -6 With
: 928 5 -3 big
: 936 5 -3 warm
: 944 3 -4 fuz
: 948 2 -3 zy
: 952 3 -4 se
: 956 2 -6 cret
: 960 7 -6 heart:
- 969 970
: 992 6 -9 Code
: 1000 3 -9 Mon
: 1004 2 -8 key
: 1008 3 -8 li
: 1012 2 -6 ~ke
: 1016 10 -8 you
- 1028 1100
: 1120 5 -9 Code
: 1128 3 -9 Mon
: 1132 2 -8 key
: 1136 3 -8 li
: 1140 2 -6 ~ke
* 1144 124 -8 you
- 1270 1271
: 1280 6 -4 Code
: 1288 3 -4 Mon
: 1292 2 -6 key
: 1296 2 -6 hang
: 1300 2 -8 a
: 1304 3 -6 round
: 1309 4 -8 at
: 1316 4 -6 front
: 1324 4 -11 desk
- 1330 1331
: 1344 3 -4 Tell
: 1348 2 -4 you
: 1352 3 -4 swea
: 1356 3 -6 ter
: 1360 2 -8 look
: 1365 4 -8 nice
- 1371 1388
: 1408 3 -6 Co
: 1412 3 -4 ~de
: 1416 3 -4 Mon
: 1420 2 -6 key
: 1424 2 -6 of
: 1428 2 -8 fer
: 1432 3 -6 buy
: 1437 3 -8 you
: 1444 6 -6 so
: 1452 4 -11 da
- 1458 1459
: 1468 2 -6 Bring
: 1472 2 -4 you
: 1476 5 -4 cup
: 1484 2 -6 bring
: 1488 2 -8 you
: 1492 6 -8 ice
- 1500 1512
: 1532 4 -4 You
: 1540 6 -1 say
: 1548 4 -4 no
: 1556 6 -3 thank
: 1564 2 -4 you
: 1568 3 -3 for
: 1572 2 -4 the
: 1576 3 -3 so
: 1580 2 -4 da
: 1584 5 -4 cause
- 1591
: 1597 2 -6 So
: 1601 2 -4 da
: 1605 5 -6 make
: 1612 4 -9 you
: 1620 5 -8 fat
- 1627 1640
: 1660 3 -3 A
: 1664 2 -1 ny
: 1668 6 -1 way
: 1676 5 -4 you
: 1684 6 -3 bu
: 1692 2 -4 sy
: 1696 2 -3 with
: 1700 2 -4 the
: 1704 3 -3 te
: 1708 2 -4 le
: 1712 6 -4 phone
- 1720
: 1724 5 -4 No
: 1732 5 -6 time
: 1740 4 -9 for
: 1748 5 -8 chat
- 1755
: 1760 3 -9 Co
: 1764 2 -8 ~de
: 1768 3 -8 Mon
: 1772 2 -6 key
: 1776 5 -4 have
- 1782
* 1784 5 -3 Long
: 1792 5 -3 walk
: 1800 5 -3 back
: 1808 4 -4 to
: 1816 3 -4 cu
: 1820 3 -6 bi
: 1824 5 -6 cle
- 1830
: 1832 2 -4 He
: 1836 4 -1 sit
: 1844 5 -1 do
: 1850 3 -3 ~wn
: 1856 2 -3 pre
: 1861 6 -4 tend
: 1872 2 -6 to
: 1876 4 -8 work
- 1882
: 1888 6 -8 Code
: 1896 3 -8 Mon
: 1900 2 -8 key
: 1904 4 -9 not
: 1912 3 -9 thin
: 1917 4 -11 king
- 1923 1924
: 1932 4 -11 So
: 1941 8 -3 straight
- 1950
: 1952 5 -8 Code
: 1960 3 -8 Mon
: 1964 2 -9 key
: 1968 4 -8 not
: 1976 3 -8 fee
: 1980 3 -9 ~
: 1984 5 -9 ling
- 1991
: 1996 4 -13 So
* 2004 9 -3 great
- 2014
: 2016 3 -3 Co
: 2020 3 -1 ~de
: 2024 3 -1 Mon
: 2028 2 -3 key
: 2032 4 -3 like
: 2040 3 -3 Fri
: 2044 7 -4 tos
- 2053 2054
: 2080 3 -3 Co
: 2084 3 -1 ~de
: 2088 3 -1 Mon
: 2092 2 -3 key
: 2096 5 -3 like
: 2104 6 -3 Tab
: 2112 3 -4 and
: 2116 3 -4 Moun
: 2120 2 -6 tain
: 2124 7 -4 Dew
- 2133 2134
: 2144 6 -4 Code
: 2152 3 -4 Mon
: 2156 3 -3 key
: 2160 3 -1 ve
: 2164 2 -1 ry
: 2168 3 -1 sim
: 2172 2 -3 ple
: 2176 9 -3 man
- 2187 2188
: 2204 2 -6 With
: 2208 6 -3 big
: 2216 6 -3 warm
: 2224 3 -4 fuz
: 2228 2 -3 zy
: 2232 3 -4 se
: 2236 2 -6 cret
: 2240 7 -6 heart:
- 2249 2250
: 2272 3 -11 Co
: 2276 3 -9 ~de
: 2280 3 -9 Mon
: 2284 3 -8 key
: 2288 3 -8 li
: 2292 2 -6 ~ke
: 2296 18 -8 you
- 2316 2380
: 2400 3 -9 Co
: 2404 3 -8 ~de
: 2408 3 -9 Mon
: 2412 2 -8 key
: 2416 3 -8 li
: 2420 2 -6 ~ke
* 2424 52 -8 you
- 2477
: 2479 2 -15 A
* 2484 6 -13 lot
- 2492 2668
: 2688 3 -6 Co
: 2692 3 -4 ~de
: 2696 3 -4 Mon
: 2700 2 -6 key
: 2704 3 -6 have
: 2708 5 -6 e
: 2716 3 -8 very
: 2723 6 -6 rea
: 2732 6 -11 son
- 2740
: 2748 2 -8 To
: 2752 3 -4 get
: 2757 4 -6 out
: 2764 3 -9 this
: 2772 6 -8 place
- 2780 2796
: 2816 6 -4 Code
: 2824 3 -4 Mon
: 2828 2 -6 key
: 2832 4 -6 just
: 2840 2 -6 keep
: 2844 4 -8 on
: 2852 4 -6 wor
: 2860 6 -11 king
- 2868
: 2876 3 -6 See
: 2880 2 -4 your
: 2884 5 -4 soft
: 2892 3 -6 pret
: 2896 2 -8 ty
: 2900 6 -8 face
- 2908 2920
: 2940 3 -4 Much
: 2948 6 -1 ra
: 2956 4 -4 ther
: 2964 5 -3 wake
: 2972 2 -4 up
: 2976 2 -3 eat
: 2980 2 -4 a
: 2984 2 -3 cof
: 2988 2 -4 fee
: 2992 5 -4 cake
- 2999
: 3004 5 -4 Take
: 3012 5 -6 bath
: 3020 5 -9 take
: 3028 4 -8 nap
- 3034 3048
: 3068 4 -4 This
: 3076 5 -1 job
: 3084 4 -4 "ful
: 3092 5 -3 fil
: 3099 3 -4 ling
: 3104 2 -3 in
: 3108 2 -4 cre
: 3112 3 -3 a
: 3116 2 -4 tive
: 3120 5 -4 way"
- 3127
: 3132 2 -6 Such
: 3136 2 -4 a
: 3140 6 -6 load
: 3148 3 -9 of
: 3156 5 -8 crap
- 3163
: 3168 3 -9 Co
: 3172 2 -8 ~de
: 3176 3 -8 Mon
: 3180 2 -6 key
: 3184 3 -4 think
- 3189
* 3192 5 -3 Some
: 3200 5 -3 day
: 3208 6 -3 he
: 3216 5 -4 have
: 3224 3 -4 e
: 3228 2 -6 very
: 3232 7 -6 thing
- 3240
: 3240 3 -6 E
: 3244 3 -4 ve
* 3248 4 -1 ~n
: 3256 3 -1 pret
: 3260 4 -3 ty
: 3268 5 -4 girl
: 3276 3 -6 li
: 3280 2 -8 ~ke
: 3285 9 -8 you
- 3295
: 3296 3 -9 Co
: 3300 2 -8 ~de
: 3304 3 -8 Mon
: 3308 2 -9 key
: 3312 4 -9 just
: 3320 3 -9 wai
: 3324 3 -11 ting
- 3329 3330
: 3340 4 -11 For
: 3348 7 -3 now
- 3357
: 3360 3 -9 Co
: 3364 3 -8 ~de
: 3368 3 -8 Mon
: 3372 2 -6 key
: 3376 4 -8 say
: 3384 3 -8 so
: 3388 3 -9 ~me
: 3393 5 -9 day
- 3400
: 3404 4 -13 Some
* 3413 7 -3 how
- 3422
: 3424 3 -3 Co
: 3428 3 -1 ~de
: 3432 3 -1 Mon
: 3436 2 -3 key
: 3440 4 -3 like
: 3448 3 -3 Fri
: 3452 7 -4 tos
- 3461 3462
: 3488 3 -3 Co
: 3492 3 -1 ~de
: 3496 3 -1 Mon
: 3500 2 -3 key
: 3504 4 -3 like
: 3512 5 -3 Tab
: 3520 3 -4 and
: 3524 3 -4 Moun
: 3528 2 -6 tain
: 3532 7 -4 Dew
- 3541 3542
: 3552 6 -4 Code
: 3560 3 -4 Mon
: 3564 3 -3 key
: 3568 3 -1 ve
: 3572 2 -1 ry
: 3576 3 -1 sim
: 3580 2 -3 ple
: 3584 9 -3 man
- 3595 3596
: 3612 2 -6 With
: 3616 5 -3 big
: 3624 6 -3 warm
: 3632 3 -4 fuz
: 3636 2 -3 zy
: 3640 3 -4 se
: 3644 2 -6 cret
: 3648 7 -6 heart:
- 3657 3658
: 3680 3 -9 Co
: 3684 3 -8 ~de
: 3688 3 -9 Mon
: 3692 2 -8 key
: 3696 3 -8 li
: 3700 2 -6 ~ke
: 3704 21 -8 you
- 3727 3788
: 3808 3 -9 Co
: 3812 3 -8 ~de
: 3816 3 -9 Mon
: 3820 2 -8 key
: 3824 3 -8 li
: 3828 2 -6 ~ke
* 3833 119 -8 you
E
@@ -0,0 +1,473 @@
#ARTIST:Boston
#TITLE:Foreplay / Long Time
#CREATOR:Eggman
#EDITION:Rock Band
#GENRE:Rock
#YEAR:1976
#LANGUAGE:English
#BPM:394.28
#GAP:166300
#START:147.0
#MP3:audio.m4a
#COVER:cover.jpg
#BACKGROUND:background.jpg
: 0 3 26 It’s
: 7 4 29 been
: 14 4 29 such
: 20 3 26 a
: 27 10 33 long
: 41 6 31 time
: 49 32 29 ~
- 83
: 100 3 24 I
: 106 4 26 think
: 112 5 29 I
: 120 4 29 should
: 126 4 31 be
: 134 5 31 go
: 140 5 29 ~
: 147 27 29 ing
: 179 9 26 yeah
- 190
: 231 2 24 And
: 238 11 33 time
: 252 5 31 does
: 258 5 29 n’t
: 265 4 29 wait
: 273 9 31 for
: 285 5 29 me
: 291 7 26 ~
- 300
: 310 3 24 It
: 318 4 26 keeps
: 326 9 29 on
: 338 24 31 roll
: 364 2 33 in’
: 367 4 31 ~
: 372 44 29 ~
- 418
* 450 12 33 Sail
: 464 5 31 on
: 470 53 29 ~
- 525
: 529 6 26 On
: 537 4 29 a
: 543 4 29 dis
: 550 4 31 tant
: 557 6 31 high
: 564 5 29 ~
: 571 29 29 way
: 603 9 31 yeah
: 613 4 33 ~
- 619
: 642 3 24 I’ve
: 650 3 29 got
: 656 3 29 to
: 663 11 33 keep
: 677 5 31 on
: 683 4 29 ~
: 690 5 29 chas
: 698 5 29 in’
: 704 3 29 a
: 710 8 31 dream
: 719 9 29 ~
- 730
: 736 3 24 I’ve
: 743 5 33 got
* 749 4 33 ta
: 756 6 34 be
* 763 4 33 on
: 770 18 33 my
* 790 5 31 ~
: 796 36 31 way
- 834
: 843 3 24 Wish
: 850 6 28 there
: 857 4 28 was
: 864 4 29 some
: 871 5 28 thing
: 877 6 28 I
: 886 7 29 could
: 898 13 26 say
: 912 10 24 ~
: 924 58 33 ~
- 984
: 1229 4 26 I’m
: 1235 5 29 just
: 1242 3 26 ~
: 1249 6 31 mov
: 1256 9 29 in’
: 1267 6 29 on
: 1276 4 26 ~
: 1281 11 24 ~
- 1294
: 1303 4 33 You’ll
: 1310 5 33 for
: 1316 4 33 get
: 1321 2 33 a
: 1325 8 34 bout
: 1336 7 33 me
: 1344 3 31 af
: 1350 5 29 ter
: 1356 6 31 I’ve
: 1366 6 29 been
: 1376 18 29 gone
- 1396
* 1445 4 26 I
: 1451 5 29 don’t
: 1458 5 26 ~
: 1464 6 31 want
* 1472 8 29 no
: 1484 6 29 more
: 1492 5 26 ~
* 1498 8 24 ~
- 1508
: 1513 3 24 It’s
: 1519 4 32 just
: 1525 4 31 out
: 1533 5 31 side
: 1539 6 29 of
: 1546 4 29 your
: 1553 8 31 front
: 1565 6 29 door
: 1572 6 24 ~
: 1579 47 26 ~
- 1628
: 1644 6 38 Ah
: 1651 10 33 yeah
: 1664 5 31 yeah
: 1670 5 29 ~
: 1677 20 31 ~
- 1699
: 1729 4 33 It’s
: 1737 4 36 been
: 1744 4 36 such
: 1751 3 33 a
: 1757 10 36 long
: 1771 12 33 time
- 1785
* 1807 5 33 It’s
* 1816 4 36 been
: 1823 4 36 such
: 1830 7 33 a
* 1840 11 38 long
* 1857 6 33 time
: 1865 4 31 ~
: 1870 36 29 ~
: 1910 8 31 oh
* 1919 2 29 ~
: 1922 2 26 ~
* 1925 3 24 ~
: 1929 3 21 ~
: 1933 3 19 ~
: 1937 3 17 ~
- 1942
: 2043 4 34 Whoa
- 2049
: 2281 5 24 Well
: 2287 5 26 ~
* 2293 5 29 I
* 2300 4 29 get
* 2309 9 33 so
: 2321 10 33 lone
: 2334 6 31 ly
: 2341 35 29 ~
- 2378
: 2394 6 26 When
: 2401 4 29 I
: 2406 5 29 am
: 2414 5 31 with
: 2421 9 31 out
: 2434 18 29 you
: 2454 4 33 ~
: 2459 1 31 ~
: 2461 5 33 ~
: 2467 2 36 ~
: 2470 2 38 ~
- 2474
: 2501 5 26 But
: 2507 4 29 in
: 2514 5 29 my
: 2521 29 33 mind
* 2554 5 36 deep
: 2561 3 32 in
* 2566 5 31 my
* 2574 1 33 mind
: 2576 3 31 ~
: 2580 11 29 ~
- 2593
: 2601 3 24 I
: 2608 4 26 can’t
: 2615 4 29 for
: 2621 5 29 get
: 2627 4 31 a
: 2633 10 31 bout
: 2647 32 29 you
: 2680 10 31 oh
: 2691 2 29 ~
: 2694 3 26 ~
: 2698 14 24 ~
- 2714
: 2740 9 33 Good
: 2753 7 31 times
: 2761 47 29 ~
- 2810
: 2812 3 24 And
: 2819 5 26 fac
: 2826 4 29 es
: 2832 4 29 that
: 2839 4 31 re
: 2845 7 31 mind
: 2853 4 29 ~
: 2860 28 29 me
: 2892 8 31 yeah
: 2901 4 33 ~
- 2907
: 2945 3 24 I’m
: 2952 10 33 tryin’
: 2965 4 31 to
: 2972 4 29 for
: 2978 4 29 get
: 2986 10 31 your
: 2998 17 29 name
- 3017
: 3023 4 24 And
: 3031 5 33 leave
: 3037 6 33 it
: 3044 6 34 all
: 3051 4 33 be
: 3058 18 33 hind
: 3077 5 31 ~
: 3084 26 31 me
- 3112
* 3131 4 24 You’re
* 3138 6 28 com
: 3146 5 28 in’
: 3152 5 29 back
: 3160 3 28 to
* 3166 11 28 find
: 3178 4 29 ~
* 3184 13 26 me
: 3198 11 24 ~
: 3211 60 33 ~
- 3273
: 3517 4 26 I’m
: 3524 4 29 just
: 3530 5 26 ~
: 3537 6 31 mov
: 3545 8 29 in’
: 3555 6 29 on
: 3562 6 26 ~
: 3569 11 24 ~
- 3582
: 3591 4 33 You’ll
: 3599 4 33 for
: 3606 3 33 get
: 3610 2 33 a
: 3614 9 34 bout
: 3625 6 33 me
: 3632 4 31 af
: 3639 5 29 ter
: 3645 6 31 I’ve
: 3654 5 29 been
: 3664 20 29 gone
- 3686
: 3731 5 26 I
: 3738 5 29 don’t
: 3744 4 26 ~
: 3751 5 31 want
: 3758 8 29 no
: 3770 7 29 more
: 3778 5 26 ~
: 3784 4 24 ~
- 3790
: 3797 4 24 It’s
: 3804 4 32 just
: 3811 4 31 out
: 3818 5 31 side
* 3824 6 29 of
: 3831 3 29 your
* 3838 8 31 front
* 3851 6 29 door
: 3858 5 24 ~
: 3864 41 26 ~
- 3907
: 3924 12 38 Yeah
: 3938 11 33 yeah
: 3950 5 31 yeah
: 3957 5 29 ~
: 3963 35 31 ~
- 4000
: 4014 5 33 It’s
: 4023 4 36 been
: 4030 3 36 such
: 4036 4 33 a
: 4043 10 36 long
: 4056 9 33 time
- 4067
* 4094 4 36 It’s
: 4101 4 36 been
: 4109 4 36 such
: 4115 4 33 a
* 4122 1 33 long
: 4125 10 41 ~
: 4140 6 32 time
: 4147 8 31 ~
* 4156 20 29 ~
- 4178
: 5341 72 41 Ah
* 5418 14 38 hey
- 5434
: 5642 4 33 It’s
: 5649 4 33 been
: 5656 5 34 such
: 5663 5 33 a
: 5670 15 33 long
: 5689 6 31 time
: 5696 31 29 ~
- 5729
: 5743 3 24 I
: 5749 5 32 think
: 5756 5 31 I
: 5764 4 31 should
: 5770 4 29 be
* 5777 8 31 go
* 5787 3 29 ~
* 5791 27 29 ing
* 5824 12 26 yeah
* 5837 1 27 ~
: 5839 3 26 ~
: 5843 13 24 ~
- 5858
: 5879 3 24 And
: 5886 10 33 time
: 5900 5 36 does
: 5907 3 38 n’t
: 5914 5 32 wait
: 5923 8 31 for
: 5933 1 32 me
: 5935 4 31 ~
: 5940 2 29 ~
: 5943 4 26 ~
- 5949
: 5961 3 24 It
: 5967 4 26 keeps
: 5974 10 29 on
: 5988 24 31 roll
: 6014 2 33 ing
: 6017 3 31 ~
: 6021 48 29 ~
- 6071
: 6086 5 26 There’s
: 6093 3 29 a
: 6100 10 33 long
: 6112 7 31 road
: 6121 46 29 ~
- 6169
: 6172 3 24 I’ve
: 6179 5 26 got
: 6185 3 29 ta
: 6192 5 29 stay
: 6198 4 31 in
: 6204 12 31 time
: 6219 28 29 with
: 6251 8 31 yeah
: 6260 3 33 ~
- 6265
: 6290 3 24 I’ve
: 6297 4 29 got
: 6304 3 29 to
: 6311 10 33 keep
: 6324 6 31 on
: 6331 3 29 ~
: 6337 5 29 chas
: 6343 6 29 in’
: 6351 4 29 that
: 6358 9 31 dream
: 6368 6 29 ~
- 6376
: 6385 4 24 Though
* 6390 6 33 I
: 6398 5 33 may
: 6404 5 33 nev
: 6411 4 33 er
: 6419 19 33 find
: 6439 4 31 ~
: 6445 27 31 it
- 6474
: 6491 6 24 I’m
: 6499 5 28 al
: 6505 5 28 ways
: 6513 4 29 just
: 6520 4 28 be
: 6525 12 28 hind
: 6538 4 29 ~
: 6545 12 26 it
: 6558 11 24 ~
: 6570 45 33 ~
- 6617
: 6872 3 26 I’m
: 6879 4 29 just
: 6886 5 26 ~
: 6892 6 33 mov
: 6899 5 33 in’
: 6905 5 31 on
: 6911 20 29 ~
: 6933 5 26 ah
- 6940
: 6978 3 33 Eh
: 6984 31 34 ~
: 7017 3 34 just
: 7021 2 32 ~
: 7024 5 36 mov
: 7030 3 36 in’
: 7034 6 34 on
: 7041 12 32 ~
: 7054 3 33 ~
- 7059
: 7066 11 40 Ah
* 7089 30 41 ~
* 7123 4 41 just
: 7130 5 35 tak
* 7137 5 35 in’
: 7143 4 36 my
: 7150 7 34 time
: 7158 3 32 ~
: 7162 27 29 ~
- 7191
: 7202 10 31 Yeah
* 7213 2 29 ~
: 7216 17 26 ~
- 7235
: 7292 24 29 Oh
: 7317 2 27 you
: 7321 4 29 know
: 7326 4 27 I
: 7331 3 29 wan
: 7335 2 27 na
: 7339 1 32 get
: 7341 9 32 on
: 7353 9 29 home
- 7364
: 7371 3 33 Don’t
: 7379 9 29 talk
- 7390
: 7398 5 33 Don’t
: 7406 9 29 stop
- 7417
: 7424 35 41 Ah
: 7462 10 43 ooh
: 7473 8 38 ~
: 7483 16 41 ~
- 7501
: 7528 11 41 Ow
: 7542 3 31 just
: 7548 6 32 tak
: 7555 5 32 in’
: 7562 4 32 my
: 7568 7 31 time
: 7576 16 29 ~
: 7595 9 31 oh
: 7605 3 29 ~
: 7609 4 26 ~
E
Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

@@ -0,0 +1 @@
{"version":"b2eba73a0a40c270bf2c9f5a","scannedAt":"2026-11-05T23:43:32.8265420+00:00","songs":[{"id":"2cca0df6596d5a87","path":"Downloads/free/Jonathan Coulton - Code Monkey/song.txt","title":"Code Monkey","artist":"Jonathan Coulton"},{"id":"f21b4b83f13c78c5","path":"Downloads/usdb/Boston - Foreplay _ Long Time/Boston - Foreplay _ Long Time.txt","title":"Foreplay / Long Time","artist":"Boston"}],"files":[{"path":"Downloads/free/Jonathan Coulton - Code Monkey/audio.mp3","size":4546294,"modified":1793918091486},{"path":"Downloads/free/Jonathan Coulton - Code Monkey/audio.ogg","size":3681919,"modified":1793918091897},{"path":"Downloads/free/Jonathan Coulton - Code Monkey/background.jpg","size":413348,"modified":1793918092130},{"path":"Downloads/free/Jonathan Coulton - Code Monkey/cover.jpg","size":106983,"modified":1793918092341},{"path":"Downloads/free/Jonathan Coulton - Code Monkey/license.txt","size":1778,"modified":1793918092537},{"path":"Downloads/free/Jonathan Coulton - Code Monkey/song.txt","size":7850,"modified":1793918092746},{"path":"Downloads/usdb/Boston - Foreplay _ Long Time/Boston - Foreplay _ Long Time.txt","size":7329,"modified":1793920412811},{"path":"Downloads/usdb/Boston - Foreplay _ Long Time/audio.m4a","size":7590152,"modified":1793920411374},{"path":"Downloads/usdb/Boston - Foreplay _ Long Time/background.jpg","size":474656,"modified":1793920412810},{"path":"Downloads/usdb/Boston - Foreplay _ Long Time/cover.jpg","size":770809,"modified":1793920412674}]}
+214
View File
@@ -0,0 +1,214 @@
#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
extern alias songserver;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using Vocaluxe.Base.SongServer;
using VocaluxeSongServer.Contracts;
using CLibraryScanner = songserver::VocaluxeSongServer.Library.CLibraryScanner;
using ServerProgram = songserver::Program;
namespace Tests.SongServer
{
[TestFixture]
public class CSongServerTest
{
private string _Root;
private string _Library;
private string _Cache;
[SetUp]
public void SetUp()
{
_Root = Path.Combine(Path.GetTempPath(), "vlx-songserver-test-" + Guid.NewGuid().ToString("N"));
_Library = Path.Combine(_Root, "library");
_Cache = Path.Combine(_Root, "cache");
Directory.CreateDirectory(Path.Combine(_Library, "A - One"));
Directory.CreateDirectory(Path.Combine(_Library, "Sub", "Künstler - Lied"));
Directory.CreateDirectory(Path.Combine(_Library, "NoSong"));
File.WriteAllText(Path.Combine(_Library, "A - One", "A - One.txt"), "#TITLE:One\n#ARTIST:A\n#MP3:One.mp3\n#COVER:cover.jpg\n#BPM:300\n: 0 4 5 La\nE\n");
File.WriteAllBytes(Path.Combine(_Library, "A - One", "One.mp3"), _RandomBytes(300000, 1));
File.WriteAllBytes(Path.Combine(_Library, "A - One", "cover.jpg"), _RandomBytes(2000, 2));
// Latin-1 encoded, like many old song files
File.WriteAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "lied.txt"),
Encoding.Latin1.GetBytes("#TITLE:Lied über\n#ARTIST:Künstler\n#AUDIO:a.ogg\n#BPM:200\n: 0 4 5 La\nE\n"));
File.WriteAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "a.ogg"), _RandomBytes(1000, 3));
File.WriteAllText(Path.Combine(_Library, "NoSong", "readme.txt"), "not a song");
}
[TearDown]
public void TearDown()
{
try
{
Directory.Delete(_Root, true);
}
catch (IOException) { }
}
private static byte[] _RandomBytes(int count, int seed)
{
var bytes = new byte[count];
new Random(seed).NextBytes(bytes);
return bytes;
}
[Test]
public void TestParseHeader()
{
var header = CLibraryScanner.ParseHeader("#TITLE:Song\r\n#ARTIST:Artist\r\n#MP3:x.mp3\r\n: 0 1 1 a\r\n#TITLE:ignored\n");
Assert.That(header, Is.Not.Null);
Assert.That(header.Title, Is.EqualTo("Song"));
Assert.That(header.Artist, Is.EqualTo("Artist"));
Assert.That(CLibraryScanner.ParseHeader("#TITLE:Song\n#ARTIST:Artist\n"), Is.Null, "no audio");
Assert.That(CLibraryScanner.ParseHeader("just text"), Is.Null);
}
[Test]
public void TestDecodeText()
{
Assert.That(CLibraryScanner.DecodeText(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes("über")).ToArray()), Is.EqualTo("über"));
Assert.That(CLibraryScanner.DecodeText(Encoding.UTF8.GetBytes("über")), Is.EqualTo("über"));
Assert.That(CLibraryScanner.DecodeText(Encoding.Latin1.GetBytes("über")), Is.EqualTo("über"));
}
[Test]
public void TestScan()
{
var scanner = new CLibraryScanner(NullLogger<CLibraryScanner>.Instance);
var snapshot = scanner.Scan(_Library);
Assert.That(snapshot.Dto.Songs.Select(s => s.Path), Is.EqualTo(new[] { "A - One/A - One.txt", "Sub/Künstler - Lied/lied.txt" }));
Assert.That(snapshot.Dto.Songs[1].Title, Is.EqualTo("Lied über"));
Assert.That(snapshot.Dto.Files.Select(f => f.Path), Does.Not.Contain("NoSong/readme.txt"), "only files in song folders are served");
Assert.That(snapshot.Dto.Files.Count, Is.EqualTo(5));
var version = snapshot.Dto.Version;
Assert.That(scanner.Scan(_Library).Dto.Version, Is.EqualTo(version), "unchanged library, same version");
File.WriteAllBytes(Path.Combine(_Library, "A - One", "One.mp3"), _RandomBytes(300001, 1));
Assert.That(scanner.Scan(_Library).Dto.Version, Is.Not.EqualTo(version), "changed file, new version");
}
private WebApplicationFactory<ServerProgram> _CreateServer(string apiKey = "")
{
return new WebApplicationFactory<ServerProgram>().WithWebHostBuilder(b =>
{
b.UseSetting("Library:Path", _Library);
b.UseSetting("Library:ApiKey", apiKey);
});
}
private static async Task<LibraryDto> _GetLibrary(HttpClient client)
{
for (var i = 0; i < 50; i++)
{
var response = await client.GetAsync("/api/v1/library");
if (response.StatusCode != HttpStatusCode.ServiceUnavailable)
{
response.EnsureSuccessStatusCode();
return JsonSerializer.Deserialize<LibraryDto>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
await Task.Delay(100); // first scan still running
}
throw new TimeoutException("library scan did not finish");
}
[Test]
public async Task TestApiKey()
{
using (var server = _CreateServer("secret"))
{
var client = server.CreateClient();
Assert.That((await client.GetAsync("/api/v1/library")).StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
client.DefaultRequestHeaders.Add("X-Api-Key", "secret");
Assert.That((await _GetLibrary(client)).Songs.Count, Is.EqualTo(2));
}
}
[Test]
public async Task TestDownloadOnDemand()
{
using (var server = _CreateServer())
{
var client = server.CreateClient();
var library = await _GetLibrary(client);
var provider = new CRemoteSongFileProvider(client, _Cache, library.Files);
var mp3 = CRemoteSongFileProvider.GetLocalPath(_Cache, "A - One/One.mp3");
// Case differs from the server file, as often in song files
var cover = CRemoteSongFileProvider.GetLocalPath(_Cache, "A - One/COVER.jpg");
var ogg = CRemoteSongFileProvider.GetLocalPath(_Cache, "Sub/Künstler - Lied/a.ogg");
Assert.That(provider.Exists(mp3), Is.True);
Assert.That(provider.IsLocal(mp3), Is.False);
Assert.That(provider.Exists(Path.Combine(_Cache, "nothing.mp3")), Is.False);
Assert.That(provider.GetProgress(new[] { mp3 }), Is.EqualTo(0f));
// Interrupted earlier download: the rest is fetched with a Range request
Directory.CreateDirectory(Path.GetDirectoryName(mp3));
File.WriteAllBytes(mp3 + ".part", _RandomBytes(300000, 1).Take(1000).ToArray());
await provider.RequestAsync(new[] { mp3, cover, ogg, mp3 });
Assert.That(provider.IsLocal(mp3) && provider.IsLocal(cover) && provider.IsLocal(ogg), Is.True);
Assert.That(File.ReadAllBytes(mp3), Is.EqualTo(File.ReadAllBytes(Path.Combine(_Library, "A - One", "One.mp3"))));
Assert.That(File.ReadAllBytes(ogg), Is.EqualTo(File.ReadAllBytes(Path.Combine(_Library, "Sub", "Künstler - Lied", "a.ogg"))));
Assert.That(File.Exists(mp3 + ".part"), Is.False);
Assert.That(provider.GetProgress(new[] { mp3, cover, ogg }), Is.EqualTo(1f));
// A changed file on the server makes the local copy outdated
var changed = library.Files.Select(f => f.Path == "A - One/One.mp3" ? new FileDto { Path = f.Path, Size = f.Size, Modified = f.Modified + 60000 } : f);
Assert.That(new CRemoteSongFileProvider(client, _Cache, changed).IsLocal(mp3), Is.False);
}
}
[Test]
public async Task TestBundleAndFileSecurity()
{
using (var server = _CreateServer())
{
var client = server.CreateClient();
await _GetLibrary(client);
var body = new StringContent(JsonSerializer.Serialize(new BundleRequest { Paths = new List<string> { "Sub/Künstler - Lied/lied.txt", "NoSong/readme.txt" } },
new JsonSerializerOptions(JsonSerializerDefaults.Web)), Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/files/bundle", body);
response.EnsureSuccessStatusCode();
using (var zip = new System.IO.Compression.ZipArchive(await response.Content.ReadAsStreamAsync()))
{
Assert.That(zip.Entries.Select(e => e.FullName), Is.EqualTo(new[] { "Sub/Künstler - Lied/lied.txt" }));
}
Assert.That((await client.GetAsync("/api/v1/files/NoSong/readme.txt")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That((await client.GetAsync("/api/v1/files/..%2F..%2Fsecret.txt")).StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
#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
extern alias songserver;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using CSourceHelper = songserver::VocaluxeSongServer.Sources.CSourceHelper;
using CUsdbSongSource = songserver::VocaluxeSongServer.Sources.CUsdbSongSource;
using CYtDlp = songserver::VocaluxeSongServer.Sources.CYtDlp;
namespace Tests.SongServer
{
[TestFixture]
public class CSongSourcesTest
{
[Test]
public void TestNormalizeAndMatch()
{
Assert.That(CSourceHelper.Normalize("Beyoncé – Halo!"), Is.EqualTo("beyonce halo"));
Assert.That(CSourceHelper.Matches("halo beyonce", "Beyoncé - Halo"), Is.True);
Assert.That(CSourceHelper.Matches("hal", "Beyoncé - Halo"), Is.True, "prefix of a word");
Assert.That(CSourceHelper.Matches("queen", "Beyoncé - Halo"), Is.False);
Assert.That(CSourceHelper.SafeName("AC/DC: Back in Black?"), Is.EqualTo("AC_DC_ Back in Black_"));
}
[Test]
public void TestUsdbList()
{
// Shape of usdb.animux.de "link=list" with details=1 (sample and cover cells have no text)
const string html = @"<table>
<tr class=""list_tr1"" data-songid=""1234"" data-lastchange=""1700000000"">
<td><audio><source src=""x.mp3""></audio></td><td><img src=""data/cover/1234.jpg""></td>
<td><a href=""?link=byartist"">Queen</a></td><td><a href=""?link=detail&id=1234"">Don&#039;t Stop Me Now</a></td>
<td>Rock</td><td>1978</td><td>SingStar</td><td>Yes</td><td>English</td><td>someone</td><td>5</td><td>1000</td>
</tr>
<tr class=""list_tr2"" data-songid=""99""><td></td><td></td><td>ABBA</td><td>Waterloo</td><td></td><td></td></tr>
</table>";
var results = CUsdbSongSource.ParseList(html);
Assert.That(results.Select(r => r.SourceId), Is.EqualTo(new[] { "1234", "99" }));
Assert.That(results[0].Artist, Is.EqualTo("Queen"));
Assert.That(results[0].Title, Is.EqualTo("Don't Stop Me Now"));
Assert.That(results[0].Details, Does.Contain("1978"));
Assert.That(results[1].Title, Is.EqualTo("Waterloo"));
}
[Test]
public void TestVideoTagAndImages()
{
var hints = CUsdbSongSource.ParseVideoTag("v=dQw4w9WgXcQ,a=https://youtu.be/aaaaaaaaaaa,co=artist/123/cover.jpg,bg=12345.jpg,co-rotate=5");
Assert.That(hints.Video, Is.EqualTo("dQw4w9WgXcQ"));
Assert.That(CYtDlp.YouTubeId(hints.Audio!), Is.EqualTo("aaaaaaaaaaa"));
Assert.That(CUsdbSongSource.ImageUrl(hints.Cover!), Is.EqualTo("https://artist/123/cover.jpg"));
Assert.That(CUsdbSongSource.ImageUrl(hints.Background!), Is.EqualTo("https://assets.fanart.tv/fanart/12345.jpg"));
Assert.That(CUsdbSongSource.ImageUrl("https://images.fanart.tv/x.jpg"), Is.EqualTo("https://assets.fanart.tv/x.jpg"));
Assert.That(CUsdbSongSource.ParseVideoTag("Queen - Song.mp4").Video, Is.Null, "a plain file name is no hint");
Assert.That(CYtDlp.FindYouTubeIds("see <a href=\"https://www.youtube.com/watch?v=abcdefghijk&t=1\">x</a> or youtu.be/abcdefghijk and https://youtube.com/embed/ZZZZZZZZZZZ"),
Is.EqualTo(new[] { "abcdefghijk", "ZZZZZZZZZZZ" }));
}
[Test]
public void TestRewriteSong()
{
const string text = "#TITLE:Song\r\n#ARTIST:Band\r\n#MP3:Band - Song.mp3\r\n#VIDEO:v=dQw4w9WgXcQ,co=1.jpg\r\n#BPM:300\r\n: 0 4 5 La\r\nE\r\n";
var result = CUsdbSongSource.RewriteSong(text, new Dictionary<string, string?> { ["MP3"] = "audio.m4a", ["VIDEO"] = "video.mp4", ["COVER"] = null });
Assert.That(result, Is.EqualTo("#TITLE:Song\n#ARTIST:Band\n#BPM:300\n#MP3:audio.m4a\n#VIDEO:video.mp4\n: 0 4 5 La\nE\n"));
}
}
}
+3
View File
@@ -15,6 +15,7 @@
<PackageReference Include="NUnit3TestAdapter" Version="6.3.1" PrivateAssets="all" />
<PackageReference Include="NunitXml.TestLogger" Version="8.1.0" />
<PackageReference Include="coverlet.collector" Version="10.1.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
<Compile Update="VocaluxeLib\Log\CLogTest.cs">
@@ -32,5 +33,7 @@
<ProjectReference Include="..\VocaluxeLib\VocaluxeLib.csproj" SetPlatform="Platform=AnyCPU" />
<ProjectReference Include="..\Vocaluxe\Vocaluxe.csproj" />
<ProjectReference Include="..\VocaluxeWeb\VocaluxeWeb.csproj" SetPlatform="Platform=AnyCPU" />
<!-- Alias: the contract types also exist in Vocaluxe (linked source) -->
<ProjectReference Include="..\VocaluxeSongServer\VocaluxeSongServer.csproj" SetPlatform="Platform=AnyCPU" Aliases="songserver" />
</ItemGroup>
</Project>
@@ -137,4 +137,8 @@
<ServerPort>3000</ServerPort>
<SongCountCoverThreshold>70</SongCountCoverThreshold>
</Server>
<SongServer>
<Url />
<ApiKey />
</SongServer>
</root>
+18
View File
@@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeTournament", "Part
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeWeb", "VocaluxeWeb\VocaluxeWeb.csproj", "{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VocaluxeSongServer", "VocaluxeSongServer\VocaluxeSongServer.csproj", "{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PartyModeRuleRoulette", "PartyModes\PartyModeRuleRoulette\PartyModeRuleRoulette.csproj", "{2E6A9B48-71C3-4D05-8F2B-C4A7D19E6E3F}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PitchTracker", "PitchTracker\PitchTracker.vcxproj", "{54A48AF7-515C-4EDD-B395-2BFFA096050A}"
@@ -264,6 +266,22 @@ Global
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x64.Build.0 = ReleaseWin|Any CPU
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.ActiveCfg = ReleaseWin|Any CPU
{4E2C9A71-6B3D-4F85-9C1E-8A7D5B2F3C60}.ReleaseWin|x86.Build.0 = ReleaseWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugLinux|x64.ActiveCfg = DebugLinux|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugLinux|x86.ActiveCfg = DebugLinux|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x64.ActiveCfg = DebugWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x64.Build.0 = DebugWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x86.ActiveCfg = DebugWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.DebugWin|x86.Build.0 = DebugWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x64.ActiveCfg = InstallerWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x64.Build.0 = InstallerWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x86.ActiveCfg = InstallerWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.InstallerWin|x86.Build.0 = InstallerWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseLinux|x64.ActiveCfg = ReleaseLinux|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseLinux|x86.ActiveCfg = ReleaseLinux|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x64.ActiveCfg = ReleaseWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x64.Build.0 = ReleaseWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x86.ActiveCfg = ReleaseWin|Any CPU
{9595FC0E-E9A4-4775-9E6B-A5004BD2DA34}.ReleaseWin|x86.Build.0 = ReleaseWin|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+40 -1
View File
@@ -18,6 +18,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using VocaluxeLib;
using VocaluxeLib.Draw;
@@ -55,6 +56,11 @@ namespace Vocaluxe.Base
private const int _PreviewStartWaitMaxTries = 10;
private static readonly object _PreviewStartHelperTaskLock = new Object();
// Song server: preview waiting for its audio file to be downloaded (main thread only)
private static CSong _PendingPreviewSong;
private static float _PendingPreviewStart;
private static Task _PendingPreviewTask;
public static bool VideoEnabled
{
get { return _CurPlayer.VideoEnabled; }
@@ -309,6 +315,16 @@ namespace Vocaluxe.Base
public static void Update()
{
if (_PendingPreviewSong != null && _PendingPreviewTask.IsCompleted)
{
var song = _PendingPreviewSong;
_PendingPreviewSong = null;
if (CSongFiles.IsLocal(song.GetMP3()))
{
LoadPreview(song, _PendingPreviewStart);
}
}
if (_PreviewStartHelperTask != null && (_PreviewPlayer.Length > 0 || _PreviewStartWaitCounter++ >= _PreviewStartWaitMaxTries))
{
lock (_PreviewStartHelperTaskLock)
@@ -419,6 +435,27 @@ namespace Vocaluxe.Base
return;
}
// Song server: download the audio first, the preview starts in Update() when it's there.
// The video is downloaded in the background (the preview shows it from the next time on).
if (!string.IsNullOrEmpty(song.Video))
{
CSongFiles.RequestAsync(new[] { song.GetVideo() });
}
if (!CSongFiles.IsLocal(song.GetMP3()))
{
if (IsPlayingPreview)
{
_PreviewPlayer.Stop();
}
_PendingPreviewSong = song;
_PendingPreviewStart = start;
_PendingPreviewTask = CSongFiles.RequestAsync(new[] { song.GetMP3() });
return;
}
_PendingPreviewSong = null;
_PreviewPlayer.Load(song);
lock (_PreviewStartHelperTaskLock)
@@ -456,6 +493,7 @@ namespace Vocaluxe.Base
public static void StopPreview()
{
_PendingPreviewSong = null;
if (!IsPlayingPreview)
{
return;
@@ -520,7 +558,8 @@ namespace Vocaluxe.Base
return;
}
foreach (var song in CSongs.AllSongs)
// Songs from the song server only when they are downloaded already
foreach (var song in CSongs.AllSongs.Where(s => CSongFiles.IsLocal(s.GetMP3())))
{
_NotPlayedFiles.Add(new CPlaylistElement(song));
}
+13
View File
@@ -249,6 +249,14 @@ namespace Vocaluxe.Base
[DefaultValue(70)]
public int SongCountCoverThreshold;
}
public struct SConfigSongServer
{
[DefaultValue("")]
public string Url;
[DefaultValue("")]
public string ApiKey;
}
#pragma warning restore 649
// ReSharper restore UnassignedField.Global
@@ -265,6 +273,7 @@ namespace Vocaluxe.Base
public SConfigVideo Video;
public SConfigRecord Record;
public SConfigServer Server;
public SConfigSongServer SongServer;
}
public static SConfig Config;
@@ -643,6 +652,10 @@ namespace Vocaluxe.Base
case "SongCountCoverThreshold":
return
"Threshold of songs for that covers will not longer be included in get-all-songs-requests (e.g. song list) (default: 70) [-1..65535] -1 => always deliver covers";
case "Url":
return "Song server (VocaluxeSongServer) address, e.g. http://192.168.1.108:8099. Empty = no song server, only local song folders";
case "ApiKey":
return "API key of the song server, if it requires one";
case "Stretch":
return "Stretch view to full window size: " + CHelper.ListStrings(Enum.GetNames(typeof(EOffOn)));
default:
+2 -1
View File
@@ -19,6 +19,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Drawing;
using System.Linq;
using Vocaluxe.Base.Fonts;
using Vocaluxe.Base.ThemeSystem;
using VocaluxeLib;
@@ -133,7 +134,7 @@ namespace Vocaluxe.Base
public IEnumerable<string> GetSongFolders()
{
return CConfig.SongFolders;
return CConfig.SongFolders.Concat(SongServer.CSongServer.SongFolders);
}
public ESongSorting GetSongSorting()
+5 -1
View File
@@ -23,6 +23,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Vocaluxe.Base.SongServer;
using VocaluxeLib;
using VocaluxeLib.Log;
using VocaluxeLib.Songs;
@@ -418,11 +419,14 @@ namespace Vocaluxe.Base
IEnumerable<string> files;
// Song server: mirror the song text files into the cache folder, which is then loaded like a song folder.
CSongServer.Sync();
using (CBenchmark.Time("List Songs"))
{
var fileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in CConfig.SongFolders)
foreach (var path in CConfig.SongFolders.Concat(CSongServer.SongFolders))
{
if (!Directory.Exists(path))
{
@@ -0,0 +1,217 @@
#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.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using VocaluxeLib.Log;
using VocaluxeLib.Songs;
using VocaluxeSongServer.Contracts;
namespace Vocaluxe.Base.SongServer
{
/// <summary>
/// Song files of the song server: the cache folder mirrors the server library, files are downloaded on demand.
/// Files outside the cache folder (local song folders) are passed through to the file system.
/// </summary>
class CRemoteSongFileProvider : ISongFileProvider
{
private const int _MaxParallelDownloads = 4;
private readonly HttpClient _Http;
private readonly string _CacheFolder;
// Full local path -> server file. Case-insensitive: song files often reference media with different case.
private readonly Dictionary<string, FileDto> _Files;
private readonly ConcurrentDictionary<string, Lazy<Task>> _Downloads = new ConcurrentDictionary<string, Lazy<Task>>(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, long> _BytesDone = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _Parallel = new SemaphoreSlim(_MaxParallelDownloads);
public CRemoteSongFileProvider(HttpClient http, string cacheFolder, IEnumerable<FileDto> files)
{
_Http = http;
_CacheFolder = cacheFolder;
_Files = new Dictionary<string, FileDto>(StringComparer.OrdinalIgnoreCase);
foreach (var file in files)
{
_Files[GetLocalPath(cacheFolder, file.Path)] = file;
}
}
public static string GetLocalPath(string cacheFolder, string relativePath)
{
return Path.GetFullPath(Path.Combine(cacheFolder, relativePath.Replace('/', Path.DirectorySeparatorChar)));
}
private FileDto _GetRemote(string path)
{
if (string.IsNullOrEmpty(path))
{
return null;
}
FileDto file;
return _Files.TryGetValue(Path.GetFullPath(path), out file) ? file : null;
}
/// <summary>True if the local copy matches the server file (same size and modification time).</summary>
public static bool IsCurrent(string localPath, FileDto file)
{
var info = new FileInfo(localPath);
if (!info.Exists || info.Length != file.Size)
{
return false;
}
var modified = new DateTimeOffset(info.LastWriteTimeUtc).ToUnixTimeMilliseconds();
return Math.Abs(modified - file.Modified) < 2000;
}
public bool Exists(string path)
{
return _GetRemote(path) != null || File.Exists(path);
}
public bool IsLocal(string path)
{
var file = _GetRemote(path);
return file == null ? File.Exists(path) : IsCurrent(_LocalPathOf(file), file);
}
private string _LocalPathOf(FileDto file)
{
return GetLocalPath(_CacheFolder, file.Path);
}
public Task RequestAsync(IEnumerable<string> paths)
{
var tasks = new List<Task>();
foreach (var path in paths)
{
var file = _GetRemote(path);
if (file == null || IsCurrent(_LocalPathOf(file), file))
{
continue;
}
var localPath = _LocalPathOf(file);
// Lazy: GetOrAdd may call the factory twice under contention, but only one download may start.
tasks.Add(_Downloads.GetOrAdd(localPath, _ => new Lazy<Task>(() => _DownloadAsync(file, localPath))).Value);
}
return Task.WhenAll(tasks);
}
public float GetProgress(IEnumerable<string> paths)
{
long total = 0, done = 0;
foreach (var path in paths)
{
var file = _GetRemote(path);
if (file == null)
{
continue;
}
total += file.Size;
var localPath = _LocalPathOf(file);
long bytes;
if (IsCurrent(localPath, file))
{
done += file.Size;
}
else if (_BytesDone.TryGetValue(localPath, out bytes))
{
done += Math.Min(bytes, file.Size);
}
}
return total == 0 ? 1f : (float)done / total;
}
private async Task _DownloadAsync(FileDto file, string localPath)
{
await _Parallel.WaitAsync().ConfigureAwait(false);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
var partPath = localPath + ".part";
long offset = File.Exists(partPath) ? new FileInfo(partPath).Length : 0;
if (offset >= file.Size)
{
offset = 0; // stale part file
}
using (var request = new HttpRequestMessage(HttpMethod.Get, CSongServer.FileUrl(file.Path)))
{
if (offset > 0)
{
// Resume an interrupted download, but only if the file hasn't changed since.
request.Headers.Range = new RangeHeaderValue(offset, null);
request.Headers.IfRange = new RangeConditionHeaderValue(DateTimeOffset.FromUnixTimeMilliseconds(file.Modified));
}
using (var response = await _Http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
var append = offset > 0 && response.StatusCode == HttpStatusCode.PartialContent;
if (!append)
{
offset = 0;
}
_BytesDone[localPath] = offset;
using (var source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var target = new FileStream(partPath, append ? FileMode.Append : FileMode.Create, FileAccess.Write, FileShare.None, 81920, true))
{
var buffer = new byte[81920];
int read;
while ((read = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
{
await target.WriteAsync(buffer, 0, read).ConfigureAwait(false);
_BytesDone.AddOrUpdate(localPath, read, (_, v) => v + read);
}
}
}
}
File.Move(partPath, localPath, true);
File.SetLastWriteTimeUtc(localPath, DateTimeOffset.FromUnixTimeMilliseconds(file.Modified).UtcDateTime);
if (!IsCurrent(localPath, file))
{
CLog.Warning("Song server file {File} has an unexpected size after download", CLog.Params(file.Path));
}
}
catch (Exception e)
{
CLog.Error(e, "Downloading {File} from the song server failed: {ExceptionMessage}", CLog.Params(file.Path, e.Message));
}
finally
{
_BytesDone.TryRemove(localPath, out _);
_Downloads.TryRemove(localPath, out _);
_Parallel.Release();
}
}
}
}
+264
View File
@@ -0,0 +1,264 @@
#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.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using VocaluxeLib.Log;
using VocaluxeLib.Songs;
using VocaluxeSongServer.Contracts;
namespace Vocaluxe.Base.SongServer
{
/// <summary>
/// Client of the song server (VocaluxeSongServer). The song text files are mirrored into a local cache folder,
/// which is then loaded like a normal song folder. Audio, video and images are downloaded on demand by
/// <see cref="CRemoteSongFileProvider" />. Without connection, the cached library of the last start is used.
/// </summary>
static class CSongServer
{
private const string _LibraryFileName = "library.json";
private const int _BundleSize = 1000;
private static readonly JsonSerializerOptions _Json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
private static HttpClient _Http;
private static string _BaseUrl;
public static bool Enabled
{
get { return !string.IsNullOrWhiteSpace(CConfig.Config.SongServer.Url); }
}
/// <summary>The local mirror of the server library, or null if no song server is configured.</summary>
public static string CacheFolder { get; private set; }
/// <summary>True if the last sync reached the server, false if the cached library was used.</summary>
public static bool Online { get; private set; }
/// <summary>Song folders to load in addition to the local ones (the cache folder if a song server is configured).</summary>
public static IEnumerable<string> SongFolders
{
get { return CacheFolder != null ? new[] { CacheFolder } : new string[0]; }
}
public static string FileUrl(string relativePath)
{
return _BaseUrl + "/api/v1/files/" + string.Join("/", relativePath.Split('/').Select(Uri.EscapeDataString));
}
private static void _Init()
{
_BaseUrl = CConfig.Config.SongServer.Url.Trim().TrimEnd('/');
if (!_BaseUrl.Contains("://"))
{
_BaseUrl = "http://" + _BaseUrl;
}
var uri = new Uri(_BaseUrl);
var folderName = uri.Host + "_" + uri.Port;
CacheFolder = Path.GetFullPath(Path.Combine(CSettings.DataFolder, "SongServer", folderName));
Directory.CreateDirectory(CacheFolder);
_Http?.Dispose();
_Http = new HttpClient(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }) { Timeout = TimeSpan.FromMinutes(30) };
if (!string.IsNullOrEmpty(CConfig.Config.SongServer.ApiKey))
{
_Http.DefaultRequestHeaders.Add("X-Api-Key", CConfig.Config.SongServer.ApiKey);
}
}
/// <summary>
/// Gets the library from the server and mirrors the song text files. Blocks; called by the song loader thread
/// before the song folders are read. Does nothing if no song server is configured.
/// </summary>
public static void Sync()
{
CacheFolder = null;
CSongFiles.Provider = new CLocalSongFileProvider();
if (!Enabled)
{
return;
}
try
{
_Init();
}
catch (Exception e)
{
CLog.Error(e, "Invalid song server address {Url}: {ExceptionMessage}", CLog.Params(CConfig.Config.SongServer.Url, e.Message));
return;
}
using (CBenchmark.Time("Sync song server"))
{
var libraryPath = Path.Combine(CacheFolder, _LibraryFileName);
var cached = _LoadCachedLibrary(libraryPath);
var library = _FetchLibrary(cached, libraryPath) ?? cached;
if (library == null)
{
CLog.Warning("Song server {Url} not reachable and no cached library", CLog.Params(_BaseUrl));
return;
}
if (Online)
{
try
{
_SyncTextFiles(library);
_RemoveStaleFiles(library);
}
catch (Exception e)
{
CLog.Error(e, "Syncing song text files from {Url} failed: {ExceptionMessage}", CLog.Params(_BaseUrl, e.Message));
}
}
CSongFiles.Provider = new CRemoteSongFileProvider(_Http, CacheFolder, library.Files);
CLog.Information("Song server {Url}: {Songs} songs ({State})", CLog.Params(_BaseUrl, library.Songs.Count, Online ? "online" : "offline, cached library"));
}
}
private static LibraryDto _LoadCachedLibrary(string libraryPath)
{
try
{
return File.Exists(libraryPath) ? JsonSerializer.Deserialize<LibraryDto>(File.ReadAllText(libraryPath), _Json) : null;
}
catch (Exception e)
{
CLog.Warning("Cached song server library {File} is unreadable: {ExceptionMessage}", CLog.Params(libraryPath, e.Message));
return null;
}
}
private static LibraryDto _FetchLibrary(LibraryDto cached, string libraryPath)
{
Online = false;
try
{
using (var request = new HttpRequestMessage(HttpMethod.Get, _BaseUrl + "/api/v1/library"))
using (var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15)))
{
if (cached != null)
{
request.Headers.TryAddWithoutValidation("If-None-Match", "\"" + cached.Version + "\"");
}
using (var response = _Http.SendAsync(request, timeout.Token).Result)
{
if (response.StatusCode == HttpStatusCode.NotModified)
{
Online = true;
return cached;
}
response.EnsureSuccessStatusCode();
var json = response.Content.ReadAsStringAsync().Result;
var library = JsonSerializer.Deserialize<LibraryDto>(json, _Json);
File.WriteAllText(libraryPath + ".tmp", json, Encoding.UTF8);
File.Move(libraryPath + ".tmp", libraryPath, true);
Online = true;
return library;
}
}
}
catch (Exception e)
{
var inner = e is AggregateException ? e.InnerException ?? e : e;
CLog.Warning("Can't reach song server {Url}: {ExceptionMessage}", CLog.Params(_BaseUrl, inner.Message));
return null;
}
}
/// <summary>Downloads the song text files that are missing or changed (as zip bundles).</summary>
private static void _SyncTextFiles(LibraryDto library)
{
var files = library.Files.ToDictionary(f => f.Path, StringComparer.Ordinal);
var missing = library.Songs
.Select(s => s.Path)
.Where(p => files.ContainsKey(p) && !CRemoteSongFileProvider.IsCurrent(CRemoteSongFileProvider.GetLocalPath(CacheFolder, p), files[p]))
.ToList();
if (missing.Count == 0)
{
return;
}
CLog.Information("Downloading {Count} song text files from the song server", CLog.Params(missing.Count));
for (var i = 0; i < missing.Count; i += _BundleSize)
{
var request = new BundleRequest { Paths = missing.Skip(i).Take(_BundleSize).ToList() };
var content = new StringContent(JsonSerializer.Serialize(request, _Json), Encoding.UTF8, "application/json");
using (var response = _Http.PostAsync(_BaseUrl + "/api/v1/files/bundle", content).Result)
{
response.EnsureSuccessStatusCode();
using (var stream = response.Content.ReadAsStreamAsync().Result)
using (var zip = new ZipArchive(stream, ZipArchiveMode.Read))
{
foreach (var entry in zip.Entries)
{
FileDto file;
if (!files.TryGetValue(entry.FullName, out file))
{
continue;
}
var localPath = CRemoteSongFileProvider.GetLocalPath(CacheFolder, file.Path);
if (!localPath.StartsWith(CacheFolder, StringComparison.OrdinalIgnoreCase))
{
continue; // never write outside the cache folder
}
Directory.CreateDirectory(Path.GetDirectoryName(localPath));
entry.ExtractToFile(localPath, true);
File.SetLastWriteTimeUtc(localPath, DateTimeOffset.FromUnixTimeMilliseconds(file.Modified).UtcDateTime);
}
}
}
}
}
/// <summary>The cache mirrors the server: files that are no longer on the server are deleted (removed songs disappear).</summary>
private static void _RemoveStaleFiles(LibraryDto library)
{
var known = new HashSet<string>(library.Files.Select(f => CRemoteSongFileProvider.GetLocalPath(CacheFolder, f.Path)), StringComparer.OrdinalIgnoreCase);
var libraryPath = Path.Combine(CacheFolder, _LibraryFileName);
foreach (var file in Directory.EnumerateFiles(CacheFolder, "*", SearchOption.AllDirectories))
{
if (known.Contains(file) || string.Equals(file, libraryPath, StringComparison.OrdinalIgnoreCase) || file.EndsWith(".part", StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
File.Delete(file);
}
catch (Exception e)
{
CLog.Warning("Can't delete {File} from the song cache: {ExceptionMessage}", CLog.Params(file, e.Message));
}
}
}
}
}
+67 -9
View File
@@ -255,6 +255,64 @@ namespace Vocaluxe.Screens
_Texts[_TextShortInfoTop].Visible = false;
}
// Song server: files of the current song being downloaded before it starts
private System.Threading.Tasks.Task _DownloadTask;
/// <summary>
/// Loads and starts the current song. Songs from the song server are downloaded first if needed;
/// UpdateGame() shows the progress and starts the song when the download is finished.
/// </summary>
private void _LoadAndStartCurrentSong()
{
var song = CGame.IsFinished() ? null : CGame.GetSong();
if (song != null)
{
var files = song.GetMediaFiles(CConfig.Config.Video.VideosInSongs == EOffOn.TR_CONFIG_ON);
if (!CSongFiles.AreLocal(files))
{
_DownloadTask = CSongFiles.RequestAsync(files);
_ShowDownloadProgress(song, files);
return;
}
}
_LoadCurrentSong();
_StartSong();
}
private void _ShowDownloadProgress(CSong song, List<string> files)
{
var percent = (int)(CSongFiles.GetProgress(files) * 100);
_ShowInfoText(CBase.Language.Translate("TR_SCREENSING_DOWNLOADING").Replace("%s", song.Artist + " - " + song.Title).Replace("%p", percent.ToString()));
}
/// <summary>Returns true while the current song is being downloaded.</summary>
private bool _UpdateDownload()
{
if (_DownloadTask == null)
{
return false;
}
var song = CGame.GetSong();
if (!_DownloadTask.IsCompleted && song != null)
{
_ShowDownloadProgress(song, song.GetMediaFiles(CConfig.Config.Video.VideosInSongs == EOffOn.TR_CONFIG_ON));
return true;
}
_DownloadTask = null;
if (song != null && !CSongFiles.IsLocal(song.GetMP3()))
{
CLog.Error("Song files of {Artist} - {Title} could not be downloaded, skipping the song", CLog.Params(song.Artist, song.Title));
}
// Video, backgrounds or cover may be missing after a failed download; the song still starts without them.
_LoadCurrentSong();
_StartSong();
return true;
}
private void _ShowInfoText(String text)
{
_Texts[_TextShortInfoTop].Text = text;
@@ -630,6 +688,7 @@ namespace Vocaluxe.Screens
public override void OnClose()
{
base.OnClose();
_DownloadTask = null;
_CloseSong();
if (_Webcam)
{
@@ -643,6 +702,11 @@ namespace Vocaluxe.Screens
#region update game
public override bool UpdateGame()
{
if (_UpdateDownload())
{
return true;
}
var finish = false;
if (CSound.IsPlaying(_CurrentStream) || CSound.IsPaused(_CurrentStream))
{
@@ -1534,9 +1598,7 @@ namespace Vocaluxe.Screens
_CloseSong();
CGame.ResetPlayer();
_LoadCurrentSong();
_StartSong();
_LoadAndStartCurrentSong();
}
/// <summary>
@@ -1551,9 +1613,7 @@ namespace Vocaluxe.Screens
CGame.NextRound();
_SetVisibility();
_LoadCurrentSong();
_StartSong();
_LoadAndStartCurrentSong();
}
/// <summary>
@@ -1586,9 +1646,7 @@ namespace Vocaluxe.Screens
}
_LoadCurrentSong();
_StartSong();
_LoadAndStartCurrentSong();
}
private void _TogglePause()
+2
View File
@@ -27,6 +27,8 @@
<Compile Remove="Base\Font\**" />
<None Remove="Animations\**;Base\Font\**" />
<Compile Remove="Lib\Draw\CDirect3D.cs;Lib\Sound\Record\NAudio\CNAudioRecord.cs;Lib\Webcam\CAForgeNet.cs" Condition="'$(IsWindowsConfig)' != 'true'" />
<!-- API types of the song server, shared with VocaluxeSongServer -->
<Compile Include="..\VocaluxeSongServer\Contracts\*.cs" Link="Base\SongServer\Contracts\%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
+38 -1
View File
@@ -338,6 +338,41 @@ namespace VocaluxeLib.Songs
return Path.Combine(Folder, Video);
}
public string GetCover()
{
return Cover == "" ? "" : Path.Combine(Folder, Cover);
}
/// <summary>
/// Full paths of the files needed to sing the song: audio (or instrumental/vocals), video, backgrounds and cover.
/// </summary>
public List<string> GetMediaFiles(bool includeVideo = true)
{
var files = new List<string> { GetMP3() };
if (HasInstrumental())
{
files.Add(GetInstrumental());
}
if (HasVocals())
{
files.Add(GetVocals());
}
if (includeVideo && !string.IsNullOrEmpty(Video))
{
files.Add(GetVideo());
}
files.AddRange(BackgroundFileNames.Where(bg => !string.IsNullOrEmpty(bg)).Select(bg => Path.Combine(Folder, bg)));
if (Cover != "")
{
files.Add(GetCover());
}
return files;
}
public void LoadSmallCover()
{
if (_CoverTextureSmall != null)
@@ -347,7 +382,9 @@ namespace VocaluxeLib.Songs
if (Cover != "")
{
if (CBase.DataBase.GetCover(Path.Combine(Folder, Cover), ref _CoverTextureSmall, CBase.Config.GetCoverSize()))
// Songs from a song server: download the cover first (cover loading runs on background threads).
CSongFiles.EnsureLocal(GetCover());
if (CBase.DataBase.GetCover(GetCover(), ref _CoverTextureSmall, CBase.Config.GetCoverSize()))
{
return;
}
+125
View File
@@ -0,0 +1,125 @@
#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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace VocaluxeLib.Songs
{
/// <summary>
/// Access to the files of a song (audio, video, cover, backgrounds).
/// Songs from a song server exist locally only as their text file at first; their other files are
/// downloaded on demand. Everything that opens a song file should check <see cref="IsLocal" /> and
/// call <see cref="RequestAsync" /> (or <see cref="EnsureLocal" /> on background threads) first.
/// </summary>
public interface ISongFileProvider
{
/// <summary>True if the file exists locally or can be downloaded.</summary>
bool Exists(string path);
/// <summary>True if the file can be opened now (exists locally and is complete).</summary>
bool IsLocal(string path);
/// <summary>Downloads the files that are not local yet. The task completes when all are local (or failed).</summary>
Task RequestAsync(IEnumerable<string> paths);
/// <summary>Progress of the downloads of these files in [0..1], 1 if all are local.</summary>
float GetProgress(IEnumerable<string> paths);
}
/// <summary>The default provider: plain local files.</summary>
public class CLocalSongFileProvider : ISongFileProvider
{
public bool Exists(string path)
{
return File.Exists(path);
}
public bool IsLocal(string path)
{
return File.Exists(path);
}
public Task RequestAsync(IEnumerable<string> paths)
{
return Task.CompletedTask;
}
public float GetProgress(IEnumerable<string> paths)
{
return 1f;
}
}
public static class CSongFiles
{
public static ISongFileProvider Provider { get; set; } = new CLocalSongFileProvider();
public static bool Exists(string path)
{
return !string.IsNullOrEmpty(path) && Provider.Exists(path);
}
public static bool IsLocal(string path)
{
return !string.IsNullOrEmpty(path) && Provider.IsLocal(path);
}
public static bool AreLocal(IEnumerable<string> paths)
{
return paths.All(IsLocal);
}
public static Task RequestAsync(IEnumerable<string> paths)
{
var missing = paths.Where(p => !string.IsNullOrEmpty(p) && !Provider.IsLocal(p)).Distinct().ToList();
return missing.Count == 0 ? Task.CompletedTask : Provider.RequestAsync(missing);
}
/// <summary>Blocks until the file is local. Only for background threads (e.g. cover loading).</summary>
public static bool EnsureLocal(string path)
{
if (IsLocal(path))
{
return true;
}
if (!Exists(path))
{
return false;
}
try
{
RequestAsync(new[] { path }).Wait();
}
catch
{
// A failed download is reported by the provider; the caller handles the missing file.
}
return IsLocal(path);
}
public static float GetProgress(IEnumerable<string> paths)
{
return Provider.GetProgress(paths.Where(p => !string.IsNullOrEmpty(p)));
}
}
}
+6 -6
View File
@@ -194,7 +194,7 @@ namespace VocaluxeLib.Songs
break;
case "MP3":
case "AUDIO":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.Audio = value;
headerFlags |= EHeaderFlags.MP3;
@@ -207,7 +207,7 @@ namespace VocaluxeLib.Songs
break;
case "INSTRUMENTAL":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.Instrumental = value;
headerFlags |= EHeaderFlags.Instrumental;
@@ -221,7 +221,7 @@ namespace VocaluxeLib.Songs
break;
case "VOCALS":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.Vocals = value;
headerFlags |= EHeaderFlags.Vocals;
@@ -298,7 +298,7 @@ namespace VocaluxeLib.Songs
break;
case "COVER":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.Cover = value;
}
@@ -309,7 +309,7 @@ namespace VocaluxeLib.Songs
break;
case "BACKGROUND":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.BackgroundFileNames.Add(value);
}
@@ -321,7 +321,7 @@ namespace VocaluxeLib.Songs
break;
case "VIDEO":
if (File.Exists(Path.Combine(_Song.Folder, value)))
if (CSongFiles.Exists(Path.Combine(_Song.Folder, value)))
{
_Song.Video = value;
}
+1 -1
View File
@@ -76,7 +76,7 @@ namespace VocaluxeLib.Utils.Player
{
var song = _Song;
return song != null && !string.IsNullOrEmpty(song.Folder) && !string.IsNullOrEmpty(song.Video) &&
File.Exists(Path.Combine(song.Folder, song.Video));
CSongFiles.IsLocal(song.GetVideo());
}
}
+116
View File
@@ -0,0 +1,116 @@
#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.Collections.Generic;
// Shared between the song server and the game (linked into Vocaluxe.csproj). Keep it free of dependencies.
// Serialized as camelCase JSON. All paths are relative to the library root and use '/'.
namespace VocaluxeSongServer.Contracts
{
/// <summary>GET /api/v1/library</summary>
public sealed class LibraryDto
{
/// <summary>Changes whenever any song file is added, removed or changed. Also sent as ETag.</summary>
public string Version { get; set; } = "";
public string ScannedAt { get; set; } = "";
public List<SongDto> Songs { get; set; } = new List<SongDto>();
/// <summary>All files in the song folders (text files, audio, video, images), downloadable via /api/v1/files/{path}.</summary>
public List<FileDto> Files { get; set; } = new List<FileDto>();
}
public sealed class SongDto
{
/// <summary>Stable id, derived from the path of the text file.</summary>
public string Id { get; set; } = "";
/// <summary>Path of the UltraStar text file.</summary>
public string Path { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
}
public sealed class FileDto
{
public string Path { get; set; } = "";
public long Size { get; set; }
/// <summary>Last modification, Unix time in milliseconds (UTC).</summary>
public long Modified { get; set; }
}
/// <summary>POST /api/v1/files/bundle: returns the files as a zip archive (entries named by their path).</summary>
public sealed class BundleRequest
{
public List<string> Paths { get; set; } = new List<string>();
}
/// <summary>GET /api/v1/sources: where new songs can be found.</summary>
public sealed class SourceDto
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public bool Enabled { get; set; }
/// <summary>Why the source is disabled (e.g. missing account), or empty.</summary>
public string Hint { get; set; } = "";
}
/// <summary>GET /api/v1/search?q=...: results of all enabled sources.</summary>
public sealed class SearchResponse
{
public List<SearchResultDto> Results { get; set; } = new List<SearchResultDto>();
/// <summary>Sources that failed for this search: source id -> error message.</summary>
public Dictionary<string, string> Errors { get; set; } = new Dictionary<string, string>();
}
public sealed class SearchResultDto
{
public string Source { get; set; } = "";
/// <summary>Id of the song within its source; used to download it.</summary>
public string SourceId { get; set; } = "";
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
/// <summary>Extra info shown with the result (language, year, license, ...).</summary>
public string Details { get; set; } = "";
public string CoverUrl { get; set; } = "";
/// <summary>True if a song with the same artist and title is already in the library.</summary>
public bool InLibrary { get; set; }
}
/// <summary>POST /api/v1/downloads</summary>
public sealed class DownloadRequest
{
public string Source { get; set; } = "";
public string SourceId { get; set; } = "";
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
}
/// <summary>GET /api/v1/downloads: queued, running and finished downloads (newest first).</summary>
public sealed class DownloadJobDto
{
public string Id { get; set; } = "";
public string Source { get; set; } = "";
public string SourceId { get; set; } = "";
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
/// <summary>queued, running, done or failed</summary>
public string State { get; set; } = "";
/// <summary>0..1</summary>
public double Progress { get; set; }
public string Message { get; set; } = "";
public string Created { get; set; } = "";
}
}
+29
View File
@@ -0,0 +1,29 @@
# Build from the repository root: docker build -f VocaluxeSongServer/Dockerfile -t vocaluxe-song-server .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY Directory.Build.props NuGet.Config ./
COPY VocaluxeSongServer/VocaluxeSongServer.csproj VocaluxeSongServer/
RUN dotnet restore VocaluxeSongServer/VocaluxeSongServer.csproj
COPY VocaluxeSongServer/ VocaluxeSongServer/
RUN dotnet publish VocaluxeSongServer/VocaluxeSongServer.csproj -c ReleaseLinux --no-restore -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# Song downloads (USDB source): yt-dlp for audio/video, ffmpeg for merging, deno as the JavaScript runtime
# yt-dlp needs for YouTube. yt-dlp is the newest release at build time; rebuild the image to update it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates curl unzip \
&& curl -fsSL -o /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
&& chmod 755 /usr/local/bin/yt-dlp \
&& curl -fsSL -o /tmp/deno.zip https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \
&& unzip -q /tmp/deno.zip -d /usr/local/bin && chmod 755 /usr/local/bin/deno && rm /tmp/deno.zip \
&& apt-get purge -y unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app .
# The song library; must be writable for downloads (new songs go to <library>/Downloads)
VOLUME /songs
ENV Library__Path=/songs \
HOME=/tmp \
XDG_CACHE_HOME=/tmp/cache
EXPOSE 8080
# Runs as the user given with --user (on Unraid 99:100 = nobody:users, the owner of the shares)
ENTRYPOINT ["dotnet", "VocaluxeSongServer.dll"]
+108
View File
@@ -0,0 +1,108 @@
using Microsoft.Extensions.Options;
namespace VocaluxeSongServer.Library
{
public sealed class CLibraryOptions
{
/// <summary>Root folder of the song library (in Docker: the mounted share, default /songs).</summary>
public string Path { get; set; } = "/songs";
/// <summary>Minutes between automatic rescans. 0 = only at startup and via POST /api/v1/library/rescan.</summary>
public int ScanIntervalMinutes { get; set; } = 10;
/// <summary>Folder below the library root for downloaded songs (one subfolder per source).</summary>
public string DownloadFolder { get; set; } = "Downloads";
/// <summary>If set, every /api request needs the header X-Api-Key with this value.</summary>
public string ApiKey { get; set; } = "";
}
/// <summary>Holds the current library snapshot and rescans it in the background.</summary>
public sealed class CLibraryIndex : BackgroundService
{
private readonly CLibraryScanner _Scanner;
private readonly CLibraryOptions _Options;
private readonly ILogger<CLibraryIndex> _Logger;
private readonly SemaphoreSlim _ScanLock = new(1, 1);
private readonly SemaphoreSlim _RescanRequested = new(0, 1);
private volatile CLibrarySnapshot? _Current;
public CLibraryIndex(CLibraryScanner scanner, IOptions<CLibraryOptions> options, ILogger<CLibraryIndex> logger)
{
_Scanner = scanner;
_Options = options.Value;
_Logger = logger;
}
/// <summary>The last completed scan, or null while the first scan is running.</summary>
public CLibrarySnapshot? Current => _Current;
public string Root => System.IO.Path.GetFullPath(_Options.Path);
/// <summary>Asks for a rescan as soon as possible (returns immediately).</summary>
public void RequestRescan()
{
if (_RescanRequested.CurrentCount == 0)
{
try
{
_RescanRequested.Release();
}
catch (SemaphoreFullException)
{
// Already requested.
}
}
}
public async Task ScanNowAsync(CancellationToken ct = default)
{
await _ScanLock.WaitAsync(ct);
try
{
if (!Directory.Exists(Root))
{
_Logger.LogError("Library folder {Root} does not exist", Root);
return;
}
var started = DateTime.UtcNow;
var snapshot = await Task.Run(() => _Scanner.Scan(Root), ct);
var changed = _Current?.Dto.Version != snapshot.Dto.Version;
_Current = snapshot;
_Logger.LogInformation("Scanned {Root}: {Songs} songs, {Files} files in {Seconds:0.0} s{Changed}", Root,
snapshot.Dto.Songs.Count, snapshot.Dto.Files.Count, (DateTime.UtcNow - started).TotalSeconds, changed ? "" : " (unchanged)");
}
finally
{
_ScanLock.Release();
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ScanNowAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception e)
{
_Logger.LogError(e, "Library scan failed");
}
var interval = _Options.ScanIntervalMinutes > 0 ? TimeSpan.FromMinutes(_Options.ScanIntervalMinutes) : Timeout.InfiniteTimeSpan;
try
{
await _RescanRequested.WaitAsync(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
}
@@ -0,0 +1,232 @@
using System.Security.Cryptography;
using System.Text;
using VocaluxeSongServer.Contracts;
namespace VocaluxeSongServer.Library
{
/// <summary>A scanned library: the DTO sent to clients plus the lookup of downloadable files.</summary>
public sealed class CLibrarySnapshot
{
public required LibraryDto Dto { get; init; }
/// <summary>Relative path ('/') -> full path, case-sensitive like the file system on the server.</summary>
public required IReadOnlyDictionary<string, string> FullPaths { get; init; }
}
/// <summary>
/// Finds UltraStar songs (text files with #TITLE, #ARTIST and #MP3/#AUDIO) below the library root.
/// Only the header of each text file is read; unchanged files are not read again on the next scan.
/// </summary>
public sealed class CLibraryScanner
{
private sealed record CTxtInfo(long Size, long Modified, CSongHeader? Header);
public sealed record CSongHeader(string Title, string Artist);
private readonly ILogger<CLibraryScanner> _Logger;
private Dictionary<string, CTxtInfo> _TxtCache = new(StringComparer.Ordinal);
public CLibraryScanner(ILogger<CLibraryScanner> logger)
{
_Logger = logger;
}
private static readonly EnumerationOptions _Recursive = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MatchCasing = MatchCasing.CaseInsensitive,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System
};
private static readonly EnumerationOptions _TopLevel = new()
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.Directory
};
public CLibrarySnapshot Scan(string root)
{
root = Path.GetFullPath(root);
var songs = new List<SongDto>();
var songFolders = new HashSet<string>(StringComparer.Ordinal);
var newCache = new Dictionary<string, CTxtInfo>(StringComparer.Ordinal);
foreach (var txt in Directory.EnumerateFiles(root, "*.txt", _Recursive))
{
FileInfo fi;
try
{
fi = new FileInfo(txt);
}
catch (Exception e)
{
_Logger.LogWarning("Can't read {File}: {Message}", txt, e.Message);
continue;
}
var modified = new DateTimeOffset(fi.LastWriteTimeUtc).ToUnixTimeMilliseconds();
if (!_TxtCache.TryGetValue(txt, out var info) || info.Size != fi.Length || info.Modified != modified)
{
info = new CTxtInfo(fi.Length, modified, ReadHeader(txt));
}
newCache[txt] = info;
if (info.Header == null)
{
continue;
}
var rel = ToRelative(root, txt);
songs.Add(new SongDto { Id = SongId(rel), Path = rel, Title = info.Header.Title, Artist = info.Header.Artist });
songFolders.Add(fi.DirectoryName!);
}
_TxtCache = newCache;
var files = new List<FileDto>();
var fullPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var folder in songFolders)
{
foreach (var file in Directory.EnumerateFiles(folder, "*", _TopLevel))
{
var fi = new FileInfo(file);
var rel = ToRelative(root, file);
files.Add(new FileDto { Path = rel, Size = fi.Length, Modified = new DateTimeOffset(fi.LastWriteTimeUtc).ToUnixTimeMilliseconds() });
fullPaths[rel] = file;
}
}
songs.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
files.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
var dto = new LibraryDto
{
Version = ComputeVersion(files),
ScannedAt = DateTimeOffset.UtcNow.ToString("O"),
Songs = songs,
Files = files
};
return new CLibrarySnapshot { Dto = dto, FullPaths = fullPaths };
}
public static string ToRelative(string root, string path)
{
return Path.GetRelativePath(root, path).Replace('\\', '/');
}
public static string SongId(string relativePath)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(relativePath));
return Convert.ToHexString(hash, 0, 8).ToLowerInvariant();
}
private static string ComputeVersion(List<FileDto> files)
{
using var sha = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var f in files)
{
sha.AppendData(Encoding.UTF8.GetBytes(f.Path + "\n" + f.Size + "\n" + f.Modified + "\n"));
}
return Convert.ToHexString(sha.GetHashAndReset(), 0, 12).ToLowerInvariant();
}
/// <summary>
/// Reads the header of an UltraStar text file. Returns null if it is not a song
/// (no #TITLE, #ARTIST or #MP3/#AUDIO tag before the first note line).
/// </summary>
public CSongHeader? ReadHeader(string path)
{
try
{
// The header is at the top; 64 KB is plenty even with long comments.
byte[] bytes;
using (var fs = File.OpenRead(path))
{
var len = (int)Math.Min(fs.Length, 64 * 1024);
bytes = new byte[len];
fs.ReadExactly(bytes, 0, len);
if (fs.Length > len)
{
// Cut at the last line break so a UTF-8 character isn't split (which would look like Latin-1).
var lastBreak = Array.LastIndexOf(bytes, (byte)'\n');
if (lastBreak > 0)
{
Array.Resize(ref bytes, lastBreak);
}
}
}
return ParseHeader(DecodeText(bytes));
}
catch (Exception e)
{
_Logger.LogWarning("Can't read {File}: {Message}", path, e.Message);
return null;
}
}
public static CSongHeader? ParseHeader(string text)
{
string? title = null, artist = null;
var hasAudio = false;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line[0] != '#')
{
break;
}
var colon = line.IndexOf(':');
if (colon < 0)
{
continue;
}
var tag = line.Substring(1, colon - 1).Trim().ToUpperInvariant();
var value = line.Substring(colon + 1).Trim();
switch (tag)
{
case "TITLE":
title = value;
break;
case "ARTIST":
artist = value;
break;
case "MP3":
case "AUDIO":
hasAudio |= value.Length > 0;
break;
}
}
return !string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist) && hasAudio ? new CSongHeader(title, artist) : null;
}
/// <summary>UTF-8 (with or without BOM) if valid, otherwise Windows-1252/Latin-1 like most old song files.</summary>
public static string DecodeText(byte[] bytes)
{
var span = bytes.AsSpan();
if (span.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }))
{
span = span[3..];
}
try
{
return new UTF8Encoding(false, true).GetString(span);
}
catch (DecoderFallbackException)
{
return Encoding.Latin1.GetString(span);
}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
using System.IO.Compression;
using System.Text.Json;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using VocaluxeSongServer.Contracts;
using VocaluxeSongServer.Library;
using VocaluxeSongServer.Sources;
var builder = WebApplication.CreateBuilder(args);
// Settings: appsettings.json section "Library", or environment variables Library__Path, Library__ApiKey, ...
builder.Services.Configure<CLibraryOptions>(builder.Configuration.GetSection("Library"));
builder.Services.AddSingleton<CLibraryScanner>();
builder.Services.AddSingleton<CLibraryIndex>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CLibraryIndex>());
// Song sources for search and download (see Sources/). USDB needs Usdb__User and Usdb__Password.
builder.Services.AddSingleton<CYtDlp>();
builder.Services.AddHttpClient("sources", c => c.Timeout = TimeSpan.FromMinutes(10));
builder.Services.AddSingleton<ISongSource>(sp => new CGitHubSongSource(sp.GetRequiredService<IHttpClientFactory>().CreateClient("sources")));
builder.Services.AddSingleton<ISongSource>(sp => new CUsdbSongSource(
new HttpClient(new HttpClientHandler { CookieContainer = new System.Net.CookieContainer(), AutomaticDecompression = System.Net.DecompressionMethods.All })
{
Timeout = TimeSpan.FromMinutes(2),
DefaultRequestHeaders = { { "User-Agent", "VocaluxeSongServer" } }
},
builder.Configuration.GetSection("Usdb").Get<CUsdbOptions>() ?? new CUsdbOptions(),
sp.GetRequiredService<CYtDlp>(), sp.GetRequiredService<ILogger<CUsdbSongSource>>()));
builder.Services.AddSingleton<CSongSearch>();
builder.Services.AddSingleton<CDownloadQueue>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<CDownloadQueue>());
builder.Services.AddResponseCompression(o =>
{
o.EnableForHttps = true;
o.Providers.Add<GzipCompressionProvider>();
o.Providers.Add<BrotliCompressionProvider>();
});
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);
var app = builder.Build();
app.UseResponseCompression();
// The web UI (wwwroot): search and download songs, browse the library. Mobile first.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapGet("/health", (CLibraryIndex index) => index.Current == null
? Results.Json(new { status = "scanning" }, statusCode: 503)
: Results.Json(new { status = "ok", songs = index.Current.Dto.Songs.Count, version = index.Current.Dto.Version }));
var api = app.MapGroup("/api/v1");
api.AddEndpointFilter(async (context, next) =>
{
var key = context.HttpContext.RequestServices.GetRequiredService<IOptions<CLibraryOptions>>().Value.ApiKey;
if (!string.IsNullOrEmpty(key) && context.HttpContext.Request.Headers["X-Api-Key"] != key)
{
return Results.Problem(title: "Missing or wrong X-Api-Key", statusCode: StatusCodes.Status401Unauthorized);
}
return await next(context);
});
// The whole library: songs and their files. Clients cache it and send If-None-Match.
api.MapGet("/library", (HttpContext ctx, CLibraryIndex index) =>
{
var snapshot = index.Current;
if (snapshot == null)
{
return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable);
}
var etag = "\"" + snapshot.Dto.Version + "\"";
ctx.Response.Headers.ETag = etag;
if (ctx.Request.Headers.IfNoneMatch == etag)
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
return Results.Json(snapshot.Dto);
});
api.MapPost("/library/rescan", (CLibraryIndex index) =>
{
index.RequestRescan();
return Results.Accepted();
});
// One file of the library. Only files listed in the library are served (no path tricks possible).
// Supports Range requests, so clients can resume downloads.
var contentTypes = new FileExtensionContentTypeProvider();
api.MapGet("/files/{**path}", (string path, CLibraryIndex index) =>
{
var snapshot = index.Current;
if (snapshot == null || !snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath))
{
return Results.NotFound();
}
if (!contentTypes.TryGetContentType(fullPath, out var contentType))
{
contentType = "application/octet-stream";
}
return Results.File(fullPath, contentType, lastModified: File.GetLastWriteTimeUtc(fullPath), enableRangeProcessing: true);
});
// Several (small) files at once as a zip archive, e.g. all song text files on the first sync.
api.MapPost("/files/bundle", async (BundleRequest request, CLibraryIndex index, HttpContext ctx) =>
{
var snapshot = index.Current;
if (snapshot == null)
{
return Results.Problem(title: "The library is being scanned, try again shortly", statusCode: StatusCodes.Status503ServiceUnavailable);
}
const int maxFiles = 2000;
if (request.Paths.Count > maxFiles)
{
return Results.Problem(title: $"At most {maxFiles} files per bundle", statusCode: StatusCodes.Status400BadRequest);
}
var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, true))
{
foreach (var path in request.Paths.Distinct())
{
if (!snapshot.FullPaths.TryGetValue(path, out var fullPath) || !File.Exists(fullPath))
{
continue;
}
var entry = zip.CreateEntry(path, CompressionLevel.Fastest);
entry.LastWriteTime = File.GetLastWriteTimeUtc(fullPath);
await using var source = File.OpenRead(fullPath);
await using var target = entry.Open();
await source.CopyToAsync(target, ctx.RequestAborted);
}
}
buffer.Position = 0;
return Results.File(buffer, "application/zip", "bundle.zip");
});
// Song search and downloads
api.MapGet("/sources", (CSongSearch search) => search.Sources.Select(s => new SourceDto
{
Id = s.Id, Name = s.Name, Description = s.Description, Enabled = s.Enabled, Hint = s.Hint
}));
api.MapGet("/search", (string q, CSongSearch search, CancellationToken ct) => search.SearchAsync(q, ct));
api.MapGet("/downloads", (CDownloadQueue queue) => queue.Jobs);
api.MapPost("/downloads", (DownloadRequest request, CDownloadQueue queue) =>
{
try
{
return Results.Json(queue.Enqueue(request));
}
catch (ArgumentException e)
{
return Results.Problem(title: e.Message, statusCode: StatusCodes.Status400BadRequest);
}
});
app.Run();
public partial class Program { }
+95
View File
@@ -0,0 +1,95 @@
# Vocaluxe song server
A central song library for Vocaluxe. The server hosts the UltraStar song folders (text file, audio, video, cover,
backgrounds). Vocaluxe mirrors the song text files at startup and downloads audio, video and images only when they are
needed, then keeps them in a local cache.
The server also has a **web UI** (`http://<server>:8099/`, made for phones, can be added to the home screen): search
song sources on the internet, download songs into the library with one tap, and browse the library.
## Running it (Docker)
```
docker run -d --name vocaluxe-song-server -p 8099:8080 --user 99:100 \
-v /mnt/user/karaoke/songs:/songs \
-e Usdb__User=... -e Usdb__Password=... \
git.disi.dev/projects/vocaluxe-song-server:latest
```
| Setting (environment variable) | Default | Meaning |
|---------------------------------|----------|----------------------------------------------------------------------|
| `Library__Path` | `/songs` | Root folder of the song library; any folder structure below it works |
| `Library__ScanIntervalMinutes` | `10` | Rescan interval; `0` = only at startup and via the rescan endpoint |
| `Library__ApiKey` | empty | If set, clients must send it as `X-Api-Key` (the web UI asks for it) |
| `Library__DownloadFolder` | `Downloads` | Where downloaded songs go, below the library root (`<source>/<Artist - Title>`) |
| `Usdb__User`, `Usdb__Password` | empty | Account at usdb.animux.de; enables the USDB source |
The library must be writable for downloads. The image contains yt-dlp (newest at build time; YouTube changes often,
so rebuild the image when downloads start failing), ffmpeg and deno.
A song is any `.txt` file with `#TITLE`, `#ARTIST` and `#MP3` (or `#AUDIO`). All files in its folder are served.
Files outside song folders are never served.
## Using it in Vocaluxe
In `config.xml`:
```xml
<SongServer>
<Url>http://192.168.1.108:8099</Url>
<ApiKey />
</SongServer>
```
Local song folders keep working alongside the server. The cache is in the Vocaluxe data folder under
`SongServer/<host>_<port>`; it mirrors the server, so songs removed on the server disappear from it. Without a
connection Vocaluxe uses the library of the last start, and every song whose files were downloaded before can be sung.
What is downloaded when:
* **Startup:** the song list and all song text files (only changed ones after the first time). Covers are downloaded by
the cover loader in the background.
* **Song selected (preview):** the audio file; the preview starts when it's there. The video is downloaded in the
background.
* **Song started:** everything still missing (video, instrumental/vocals, backgrounds). The sing screen shows the
progress and starts the song when the download is finished.
## Song sources (search and download)
Research (2026-11): where UltraStar songs come from.
| Source | What it has | Used here |
|---|---|---|
| [UltraStar-Deluxe/songs](https://github.com/UltraStar-Deluxe/songs) | ~40 Creative Commons / public domain songs **with audio** and covers | **Yes**, source `free`, no account |
| [USDB](https://usdb.animux.de) | The largest database (tens of thousands of songs), only the text files; free account needed. Songs reference audio/video (mostly YouTube) and covers (fanart.tv) in `#VIDEO` (`v=`, `a=`, `co=`, `bg=`) or in comments | **Yes**, source `usdb`: text from USDB, media via yt-dlp, like [usdb_syncer](https://github.com/bohning/usdb_syncer) |
| [UltraStar-es](https://ultrastar-es.org/en/canciones) | Large Spanish database (also other languages) | Not yet |
| [Performous packs](https://performous.org/songs) | 4 zip packs (libre, restricted, Jonathan Coulton, Shearer; CC licenses) | Not yet (Coulton songs are in `free`) |
| TXT-only GitHub repos (razzertronic/usdx-songs, derco0n/UltrastarSongs, ...) | Text files without media | Not yet |
| [UltraSinger](https://github.com/rakuri255/UltraSinger) | Creates a song from any audio with AI | Planned: songs that exist nowhere |
Songs, videos and lyrics can be copyrighted. The `free` source only has openly licensed songs; what you download from
USDB/YouTube is your own responsibility.
New sources implement `Sources/ISongSource.cs` (search + download into a folder) and are registered in `Program.cs`.
Downloads run one at a time; a song is downloaded into `Downloads/.incoming/<job>` and moved into place when complete.
## API
All endpoints are under `/api/v1` (plus `GET /health`). Paths are relative to the library root and use `/`.
The types are in `Contracts/LibraryDtos.cs`, which Vocaluxe links in.
| Endpoint | What it does |
|------------------------------|-------------------------------------------------------------------------------------|
| `GET /library` | Songs (id, path, title, artist) and all files (path, size, modified); ETag/304 |
| `GET /files/{path}` | One file, with Range support (resumable downloads) |
| `POST /files/bundle` | Several files as a zip, body `{"paths": [...]}` (max. 2000), used for text files |
| `POST /library/rescan` | Rescan now instead of waiting for the interval |
| `GET /sources` | Song sources, enabled or not (with the reason) |
| `GET /search?q=` | Search all enabled sources at once; results say whether a song is in the library |
| `GET /downloads` | Download jobs with state and progress |
| `POST /downloads` | Queue a download: `{"source", "sourceId", "artist", "title"}` from a search result |
## Building and releasing
`dotnet run --project VocaluxeSongServer -- --Library:Path=D:\Songs --urls http://localhost:8099` runs it locally.
The image is built by `.gitea/workflows/song-server.yml`: set `VERSION`, then push the tag `songserver-v<version>`
(pushes `:<version>` and `:latest`). Tests: `Tests/SongServer`.
@@ -0,0 +1,131 @@
using System.Text.Json;
using VocaluxeSongServer.Contracts;
namespace VocaluxeSongServer.Sources
{
/// <summary>
/// Openly licensed songs with all media from a GitHub repository, by default UltraStar-Deluxe/songs
/// (Creative Commons / public domain; one song per folder, license.txt in every folder).
/// The repository tree is read with one API call and cached for a day (the API allows 60 calls/hour without token).
/// </summary>
public sealed class CGitHubSongSource : ISongSource
{
private sealed record CSong(string Folder, string Artist, string Title, string License, List<(string Path, long Size)> Files);
private readonly HttpClient _Http;
private readonly string _Repo;
private readonly string _Branch;
private readonly SemaphoreSlim _Lock = new(1, 1);
private List<CSong>? _Catalog;
private DateTime _CatalogTime;
public CGitHubSongSource(HttpClient http, string repo = "UltraStar-Deluxe/songs", string branch = "master")
{
_Http = http;
_Repo = repo;
_Branch = branch;
}
public string Id => "free";
public string Name => "Free songs";
public string Description => "Creative Commons and public domain songs with audio, from github.com/" + _Repo;
public bool Enabled => true;
public string Hint => "";
private async Task<List<CSong>> _GetCatalogAsync(CancellationToken ct)
{
await _Lock.WaitAsync(ct);
try
{
if (_Catalog != null && DateTime.UtcNow - _CatalogTime < TimeSpan.FromDays(1))
{
return _Catalog;
}
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.github.com/repos/{_Repo}/git/trees/{_Branch}?recursive=1");
request.Headers.UserAgent.ParseAdd("VocaluxeSongServer");
using var response = await _Http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
var files = doc.RootElement.GetProperty("tree").EnumerateArray()
.Where(e => e.GetProperty("type").GetString() == "blob")
.Select(e => (Path: e.GetProperty("path").GetString()!, Size: e.TryGetProperty("size", out var s) ? s.GetInt64() : 0))
.ToList();
var songs = new List<CSong>();
foreach (var folder in files.GroupBy(f => f.Path.Contains('/') ? f.Path[..f.Path.LastIndexOf('/')] : ""))
{
var hasSong = folder.Any(f => f.Path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) &&
!f.Path.EndsWith("/license.txt", StringComparison.OrdinalIgnoreCase));
if (!hasSong || folder.Key == "")
{
continue;
}
// Folder names are "Artist - Title"; the category is the parent folder ("Creative Commons", ...).
var name = folder.Key[(folder.Key.LastIndexOf('/') + 1)..];
var category = folder.Key.Contains('/') ? folder.Key[..folder.Key.LastIndexOf('/')] : "";
var dash = name.IndexOf(" - ", StringComparison.Ordinal);
var artist = dash > 0 ? name[..dash] : "";
var title = dash > 0 ? name[(dash + 3)..] : name;
songs.Add(new CSong(folder.Key, artist, title, category, folder.ToList()));
}
_Catalog = songs;
_CatalogTime = DateTime.UtcNow;
return songs;
}
finally
{
_Lock.Release();
}
}
private string _RawUrl(string path)
{
return $"https://raw.githubusercontent.com/{_Repo}/{_Branch}/" + string.Join('/', path.Split('/').Select(Uri.EscapeDataString));
}
public async Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct)
{
var normalized = CSourceHelper.Normalize(query);
var catalog = await _GetCatalogAsync(ct);
return catalog.Where(s => CSourceHelper.Matches(normalized, s.Artist + " " + s.Title))
.Select(s =>
{
var cover = s.Files.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f.Path).Equals("cover", StringComparison.OrdinalIgnoreCase)).Path;
return new SearchResultDto
{
Source = Id,
SourceId = s.Folder,
Artist = s.Artist,
Title = s.Title,
Details = $"{s.License} · {s.Files.Sum(f => f.Size) / 1_000_000.0:0.#} MB",
CoverUrl = cover != null ? _RawUrl(cover) : ""
};
})
.ToList();
}
public async Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct)
{
var song = (await _GetCatalogAsync(ct)).FirstOrDefault(s => s.Folder == sourceId) ?? throw new InvalidOperationException("Song not found in " + _Repo);
var total = Math.Max(1, song.Files.Sum(f => f.Size));
long done = 0;
foreach (var file in song.Files)
{
var target = Path.Combine(folder, CSourceHelper.SafeName(Path.GetFileName(file.Path)));
using var response = await _Http.GetAsync(_RawUrl(file.Path), HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(ct);
await using var stream = File.Create(target);
await CSourceHelper.CopyAsync(source, stream, n =>
{
done += n;
progress.Report((double)done / total);
}, ct);
}
}
}
}
+223
View File
@@ -0,0 +1,223 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using VocaluxeSongServer.Contracts;
using VocaluxeSongServer.Library;
namespace VocaluxeSongServer.Sources
{
/// <summary>Searches all enabled sources at once.</summary>
public sealed class CSongSearch
{
private readonly IReadOnlyList<ISongSource> _Sources;
private readonly CLibraryIndex _Index;
private readonly ILogger<CSongSearch> _Logger;
public CSongSearch(IEnumerable<ISongSource> sources, CLibraryIndex index, ILogger<CSongSearch> logger)
{
_Sources = sources.ToList();
_Index = index;
_Logger = logger;
}
public IReadOnlyList<ISongSource> Sources => _Sources;
public ISongSource? GetSource(string id)
{
return _Sources.FirstOrDefault(s => s.Id == id);
}
public static string SongKey(string artist, string title)
{
return CSourceHelper.Normalize(artist) + "|" + CSourceHelper.Normalize(title);
}
public async Task<SearchResponse> SearchAsync(string query, CancellationToken ct)
{
var response = new SearchResponse();
if (CSourceHelper.Normalize(query).Length < 2)
{
return response;
}
var inLibrary = new HashSet<string>((_Index.Current?.Dto.Songs ?? new List<SongDto>()).Select(s => SongKey(s.Artist, s.Title)));
var tasks = _Sources.Where(s => s.Enabled).Select(async source =>
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(20));
try
{
return (source, results: await source.SearchAsync(query, timeout.Token), error: (string?)null);
}
catch (Exception e) when (!ct.IsCancellationRequested)
{
_Logger.LogWarning("Search in {Source} failed: {Message}", source.Id, e.Message);
return (source, results: new List<SearchResultDto>(), error: e is OperationCanceledException ? "timeout" : e.Message);
}
}).ToList();
foreach (var (source, results, error) in await Task.WhenAll(tasks))
{
if (error != null)
{
response.Errors[source.Id] = error;
}
foreach (var r in results)
{
r.InLibrary = inLibrary.Contains(SongKey(r.Artist, r.Title));
response.Results.Add(r);
}
}
// Best matches first: artist/title starting with the query, then alphabetically.
var q = CSourceHelper.Normalize(query);
response.Results = response.Results
.OrderByDescending(r => CSourceHelper.Normalize(r.Artist + " " + r.Title).StartsWith(q) || CSourceHelper.Normalize(r.Title).StartsWith(q))
.ThenBy(r => r.Artist, StringComparer.OrdinalIgnoreCase)
.ThenBy(r => r.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
return response;
}
}
/// <summary>
/// Runs downloads one after another in the background. A song is downloaded into a temporary folder and moved to
/// "&lt;library&gt;/&lt;DownloadFolder&gt;/&lt;source&gt;/&lt;Artist - Title&gt;" when complete; then the library is rescanned.
/// </summary>
public sealed class CDownloadQueue : BackgroundService
{
private sealed class CJob
{
public required DownloadJobDto Dto;
public required ISongSource Source;
}
private readonly Channel<CJob> _Queue = Channel.CreateUnbounded<CJob>();
private readonly ConcurrentDictionary<string, CJob> _Jobs = new();
private readonly CLibraryIndex _Index;
private readonly CSongSearch _Search;
private readonly CLibraryOptions _Options;
private readonly ILogger<CDownloadQueue> _Logger;
public CDownloadQueue(CLibraryIndex index, CSongSearch search, IOptions<CLibraryOptions> options, ILogger<CDownloadQueue> logger)
{
_Index = index;
_Search = search;
_Options = options.Value;
_Logger = logger;
}
public IEnumerable<DownloadJobDto> Jobs => _Jobs.Values.Select(j => j.Dto).OrderByDescending(j => j.Created);
/// <summary>Queues a download. Returns the existing job if the same song is already queued or running.</summary>
public DownloadJobDto Enqueue(DownloadRequest request)
{
var source = _Search.GetSource(request.Source);
if (source == null || !source.Enabled)
{
throw new ArgumentException("Unknown or disabled source: " + request.Source);
}
var existing = _Jobs.Values.FirstOrDefault(j => j.Dto.Source == request.Source && j.Dto.SourceId == request.SourceId && j.Dto.State is "queued" or "running");
if (existing != null)
{
return existing.Dto;
}
var job = new CJob
{
Source = source,
Dto = new DownloadJobDto
{
Id = Guid.NewGuid().ToString("N")[..12],
Source = request.Source,
SourceId = request.SourceId,
Artist = request.Artist,
Title = request.Title,
State = "queued",
Created = DateTimeOffset.UtcNow.ToString("O")
}
};
_Jobs[job.Dto.Id] = job;
_Queue.Writer.TryWrite(job);
// Keep the list short: forget old finished jobs.
foreach (var old in _Jobs.Values.Where(j => j.Dto.State is "done" or "failed").OrderByDescending(j => j.Dto.Created).Skip(50).ToList())
{
_Jobs.TryRemove(old.Dto.Id, out _);
}
return job.Dto;
}
public string DownloadRoot => Path.Combine(_Index.Root, _Options.DownloadFolder);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in _Queue.Reader.ReadAllAsync(stoppingToken))
{
await _RunAsync(job, stoppingToken);
}
}
private async Task _RunAsync(CJob job, CancellationToken ct)
{
var dto = job.Dto;
dto.State = "running";
var name = CSourceHelper.SafeName(dto.Artist.Length > 0 ? dto.Artist + " - " + dto.Title : dto.Title);
if (name.Length == 0)
{
name = dto.Source + "-" + CSourceHelper.SafeName(dto.SourceId);
}
var target = Path.Combine(DownloadRoot, dto.Source, name);
var temp = Path.Combine(DownloadRoot, ".incoming", dto.Id);
try
{
if (Directory.Exists(target))
{
dto.State = "done";
dto.Progress = 1;
dto.Message = "Already downloaded";
return;
}
Directory.CreateDirectory(temp);
await job.Source.DownloadAsync(dto.SourceId, temp, new Progress<double>(p => dto.Progress = Math.Clamp(p, 0, 1)), ct);
if (!Directory.EnumerateFiles(temp, "*.txt").Any(f => !Path.GetFileName(f).Equals("license.txt", StringComparison.OrdinalIgnoreCase)))
{
throw new InvalidOperationException("The download contains no song file");
}
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
Directory.Move(temp, target);
dto.State = "done";
dto.Progress = 1;
dto.Message = "";
_Logger.LogInformation("Downloaded {Artist} - {Title} from {Source}", dto.Artist, dto.Title, dto.Source);
_Index.RequestRescan();
}
catch (Exception e)
{
dto.State = "failed";
dto.Message = e is OperationCanceledException ? "cancelled" : e.Message;
_Logger.LogWarning("Download of {Artist} - {Title} from {Source} failed: {Message}", dto.Artist, dto.Title, dto.Source, e.Message);
}
finally
{
try
{
if (Directory.Exists(temp))
{
Directory.Delete(temp, true);
}
}
catch (IOException)
{
// left for the next start
}
}
}
}
}
@@ -0,0 +1,369 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using VocaluxeSongServer.Contracts;
namespace VocaluxeSongServer.Sources
{
public sealed class CUsdbOptions
{
public string User { get; set; } = "";
public string Password { get; set; } = "";
}
/// <summary>
/// USDB (usdb.animux.de), the largest UltraStar database. It has only the song text files and needs a (free)
/// account. Audio, video and images come from the sources the song references: the #VIDEO tag
/// ("v=&lt;YouTube&gt;,a=&lt;YouTube&gt;,co=&lt;fanart.tv&gt;,bg=...", the format of usdb_syncer) or YouTube links in the
/// comments. Media are downloaded with yt-dlp.
/// </summary>
public sealed partial class CUsdbSongSource : ISongSource
{
public const string BaseUrl = "https://usdb.animux.de/";
private const string _NotLoggedIn = "You are not logged in";
private const string _LoginInvalid = "Login or Password invalid";
private readonly HttpClient _Http;
private readonly CUsdbOptions _Options;
private readonly CYtDlp _YtDlp;
private readonly ILogger<CUsdbSongSource> _Logger;
private readonly SemaphoreSlim _LoginLock = new(1, 1);
private bool _LoggedIn;
/// <param name="http">A client with its own cookie container (the USDB session is a cookie).</param>
public CUsdbSongSource(HttpClient http, CUsdbOptions options, CYtDlp ytDlp, ILogger<CUsdbSongSource> logger)
{
_Http = http;
_Options = options;
_YtDlp = ytDlp;
_Logger = logger;
}
public string Id => "usdb";
public string Name => "USDB";
public string Description => "usdb.animux.de, the largest UltraStar database (notes and lyrics); audio and video from the YouTube links of the song";
public bool Enabled => _Options.User.Length > 0 && _Options.Password.Length > 0;
public string Hint => Enabled
? (_YtDlp.Available ? "" : "yt-dlp is not installed, songs can't be downloaded")
: "Needs a free account at usdb.animux.de (settings Usdb__User and Usdb__Password)";
#region HTTP
private async Task _LoginAsync(CancellationToken ct)
{
await _LoginLock.WaitAsync(ct);
try
{
if (_LoggedIn)
{
return;
}
var form = new FormUrlEncodedContent(new Dictionary<string, string> { ["user"] = _Options.User, ["pass"] = _Options.Password, ["login"] = "Login" });
using var response = await _Http.PostAsync(BaseUrl, form, ct);
var html = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode || html.Contains(_LoginInvalid))
{
throw new InvalidOperationException("USDB login failed (check user and password)");
}
_LoggedIn = true;
}
finally
{
_LoginLock.Release();
}
}
/// <summary>Sends a request (logs in first and again when the session expired) and returns the page.</summary>
private async Task<string> _RequestAsync(Func<HttpRequestMessage> createRequest, CancellationToken ct)
{
for (var attempt = 0; attempt < 2; attempt++)
{
await _LoginAsync(ct);
using var request = createRequest();
using var response = await _Http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync(ct);
var html = Encoding.UTF8.GetString(bytes);
if (!html.Contains(_NotLoggedIn))
{
return html;
}
_LoggedIn = false;
}
throw new InvalidOperationException("USDB: not logged in after login");
}
#endregion
#region Search
[GeneratedRegex(@"<tr class=""list_tr\d""[^>]*data-songid=""(\d+)""[^>]*>(.*?)</tr>", RegexOptions.Singleline)]
private static partial Regex _Row();
[GeneratedRegex(@"<td[^>]*>(.*?)</td>", RegexOptions.Singleline)]
private static partial Regex _Cell();
[GeneratedRegex(@"<[^>]+>")]
private static partial Regex _Tag();
private static string _Text(string html)
{
return WebUtility.HtmlDecode(_Tag().Replace(html, " ")).Trim();
}
/// <summary>Parses the result list (link=list with details=1): id, artist, title and the year if there is one.</summary>
public static List<SearchResultDto> ParseList(string html)
{
var results = new List<SearchResultDto>();
foreach (Match row in _Row().Matches(html))
{
// Cells: sample, cover (no text), artist, title, genre, year, edition, golden notes, language, creator, ...
var cells = _Cell().Matches(row.Groups[2].Value).Select(c => _Text(c.Groups[1].Value)).Where(t => t.Length > 0).ToList();
if (cells.Count < 2)
{
continue;
}
var id = row.Groups[1].Value;
var year = cells.Skip(2).FirstOrDefault(c => Regex.IsMatch(c, @"^(19|20)\d\d$"));
results.Add(new SearchResultDto
{
Source = "usdb",
SourceId = id,
Artist = cells[0],
Title = cells[1],
Details = "USDB #" + id + (year != null ? " · " + year : ""),
CoverUrl = BaseUrl + "data/cover/" + id + ".jpg"
});
}
return results;
}
public async Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct)
{
// USDB searches artist and title separately; a query matches either.
async Task<List<SearchResultDto>> Search(string field)
{
var html = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Post, BaseUrl + "index.php?link=list")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
[field] = query, ["order"] = "rating", ["ud"] = "desc", ["limit"] = "50", ["details"] = "1", ["start"] = "0"
})
}, ct);
return ParseList(html);
}
var byArtist = await Search("interpret");
var byTitle = await Search("title");
return byArtist.Concat(byTitle).GroupBy(r => r.SourceId).Select(g => g.First()).ToList();
}
#endregion
#region Download
[GeneratedRegex(@"<textarea[^>]*>(.*?)</textarea>", RegexOptions.Singleline)]
private static partial Regex _TextArea();
/// <summary>Media hints of a song text file (#VIDEO tag in the usdb_syncer format).</summary>
public sealed record CMediaHints(string? Video, string? Audio, string? Cover, string? Background);
public static CMediaHints ParseVideoTag(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('='))
{
return new CMediaHints(null, null, null, null);
}
var pairs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in value.Split(','))
{
var eq = pair.IndexOf('=');
if (eq > 0)
{
pairs[pair[..eq].Trim()] = pair[(eq + 1)..].Trim().Replace("%2C", ",");
}
}
return new CMediaHints(pairs.GetValueOrDefault("v"), pairs.GetValueOrDefault("a"), pairs.GetValueOrDefault("co"), pairs.GetValueOrDefault("bg"));
}
/// <summary>URL of a cover/background hint: full URL, host/path, or a fanart.tv id.</summary>
public static string ImageUrl(string source)
{
if (source.Contains("://"))
{
return source.Replace("images.fanart.tv", "assets.fanart.tv");
}
return source.Contains('/') ? "https://" + source : "https://assets.fanart.tv/fanart/" + source;
}
/// <summary>Splits a song text file into header tags (in order) and the rest (notes).</summary>
public static (List<(string Tag, string Value)> Header, string Body) SplitSong(string text)
{
var header = new List<(string, string)>();
var lines = text.Replace("\r\n", "\n").Split('\n');
var i = 0;
for (; i < lines.Length; i++)
{
var line = lines[i].Trim();
if (line.Length == 0)
{
continue;
}
if (line[0] != '#')
{
break;
}
var colon = line.IndexOf(':');
header.Add(colon > 0 ? (line[1..colon].Trim().ToUpperInvariant(), line[(colon + 1)..].Trim()) : (line[1..].Trim().ToUpperInvariant(), ""));
}
return (header, string.Join("\n", lines.Skip(i)));
}
/// <summary>The song text with the media tags replaced by the downloaded files (null = tag removed).</summary>
public static string RewriteSong(string text, IReadOnlyDictionary<string, string?> media)
{
var (header, body) = SplitSong(text);
var sb = new StringBuilder();
foreach (var (tag, value) in header)
{
if (tag is "MP3" or "AUDIO" or "VIDEO" or "COVER" or "BACKGROUND" or "ENCODING")
{
continue;
}
sb.Append('#').Append(tag).Append(':').Append(value).Append('\n');
}
foreach (var (tag, value) in media)
{
if (!string.IsNullOrEmpty(value))
{
sb.Append('#').Append(tag).Append(':').Append(value).Append('\n');
}
}
return sb.Append(body.TrimEnd('\n')).Append('\n').ToString();
}
public async Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct)
{
if (!int.TryParse(sourceId, out var id))
{
throw new ArgumentException("Invalid USDB id " + sourceId);
}
if (!_YtDlp.Available)
{
throw new InvalidOperationException("yt-dlp is not installed, USDB songs can't be downloaded");
}
var page = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Post, BaseUrl + "index.php?link=gettxt&id=" + id)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string> { ["wd"] = "1" })
}, ct);
var area = _TextArea().Match(page);
if (!area.Success)
{
throw new InvalidOperationException("USDB returned no song text for #" + id);
}
var text = WebUtility.HtmlDecode(area.Groups[1].Value);
var (header, _) = SplitSong(text);
var hints = ParseVideoTag(header.FirstOrDefault(h => h.Tag == "VIDEO").Value);
var artist = header.FirstOrDefault(h => h.Tag == "ARTIST").Value ?? "";
var title = header.FirstOrDefault(h => h.Tag == "TITLE").Value ?? "";
progress.Report(0.02);
// Media: the #VIDEO hints, otherwise the YouTube links in the comments of the song page.
var videoId = hints.Video != null ? CYtDlp.YouTubeId(hints.Video) : null;
var audioId = hints.Audio != null ? CYtDlp.YouTubeId(hints.Audio) : null;
if (videoId == null && audioId == null)
{
var detail = await _RequestAsync(() => new HttpRequestMessage(HttpMethod.Get, BaseUrl + "index.php?link=detail&id=" + id), ct);
videoId = CYtDlp.FindYouTubeIds(detail).FirstOrDefault();
}
audioId ??= videoId;
if (audioId == null)
{
throw new InvalidOperationException("No audio source known for this song (no YouTube link in the song or its comments)");
}
var media = new Dictionary<string, string?>();
media["MP3"] = await _YtDlp.DownloadAsync("https://www.youtube.com/watch?v=" + audioId, folder, "audio",
"bestaudio[ext=m4a]/bestaudio", new CScaledProgress(progress, 0.05, 0.45), ct);
if (videoId != null)
{
try
{
media["VIDEO"] = await _YtDlp.DownloadAsync("https://www.youtube.com/watch?v=" + videoId, folder, "video",
"bestvideo[height<=720][ext=mp4]/bestvideo[height<=720]/best[height<=720]", new CScaledProgress(progress, 0.45, 0.9), ct);
}
catch (Exception e) when (e is not OperationCanceledException)
{
_Logger.LogWarning("USDB #{Id}: video download failed, the song is saved without video: {Message}", id, e.Message);
}
}
media["COVER"] = await _DownloadImageAsync(hints.Cover != null ? ImageUrl(hints.Cover) : (videoId ?? audioId) is { } yt ? $"https://i.ytimg.com/vi/{yt}/hqdefault.jpg" : null,
folder, "cover", ct);
media["BACKGROUND"] = await _DownloadImageAsync(hints.Background != null ? ImageUrl(hints.Background) : null, folder, "background", ct);
var name = CSourceHelper.SafeName(artist + " - " + title);
await File.WriteAllTextAsync(Path.Combine(folder, name + ".txt"), RewriteSong(text, media), new UTF8Encoding(true), ct);
progress.Report(1);
}
private async Task<string?> _DownloadImageAsync(string? url, string folder, string baseName, CancellationToken ct)
{
if (url == null)
{
return null;
}
try
{
using var response = await _Http.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var ext = response.Content.Headers.ContentType?.MediaType == "image/png" ? ".png" : ".jpg";
await File.WriteAllBytesAsync(Path.Combine(folder, baseName + ext), await response.Content.ReadAsByteArrayAsync(ct), ct);
return baseName + ext;
}
catch (Exception e) when (e is not OperationCanceledException)
{
_Logger.LogInformation("Image {Url} not downloaded: {Message}", url, e.Message);
return null;
}
}
#endregion
}
/// <summary>Maps the progress of a part of a job (0..1) to a range of the whole job.</summary>
public sealed class CScaledProgress : IProgress<double>
{
private readonly IProgress<double> _Target;
private readonly double _From;
private readonly double _To;
public CScaledProgress(IProgress<double> target, double from, double to)
{
_Target = target;
_From = from;
_To = to;
}
public void Report(double value)
{
_Target.Report(_From + (_To - _From) * Math.Clamp(value, 0, 1));
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
namespace VocaluxeSongServer.Sources
{
/// <summary>Downloads audio/video with yt-dlp (installed in the Docker image; ffmpeg is needed for merging).</summary>
public sealed partial class CYtDlp
{
private readonly string _Executable;
private readonly ILogger<CYtDlp> _Logger;
public CYtDlp(IConfiguration config, ILogger<CYtDlp> logger)
{
_Executable = config["YtDlp:Path"] is { Length: > 0 } path ? path : "yt-dlp";
_Logger = logger;
}
private bool? _Available;
public bool Available
{
get
{
if (_Available == null)
{
try
{
using var p = Process.Start(new ProcessStartInfo(_Executable, "--version") { RedirectStandardOutput = true, RedirectStandardError = true });
p!.WaitForExit(15000);
_Available = p.ExitCode == 0;
}
catch (Exception)
{
_Available = false;
}
}
return _Available.Value;
}
}
[GeneratedRegex(@"(?:youtube\.com/(?:watch\?(?:.*&)?v=|embed/|shorts/|v/)|youtu\.be/)([A-Za-z0-9_-]{11})")]
private static partial Regex _YouTubeUrl();
[GeneratedRegex(@"^[A-Za-z0-9_-]{11}$")]
private static partial Regex _YouTubeIdOnly();
[GeneratedRegex(@"\[download\]\s+([\d.]+)%")]
private static partial Regex _ProgressLine();
/// <summary>A YouTube id from an id or URL, or null.</summary>
public static string? YouTubeId(string value)
{
value = value.Trim();
if (_YouTubeIdOnly().IsMatch(value))
{
return value;
}
var m = _YouTubeUrl().Match(value);
return m.Success ? m.Groups[1].Value : null;
}
/// <summary>All YouTube ids in a text (e.g. HTML of comments), in order, without duplicates.</summary>
public static List<string> FindYouTubeIds(string text)
{
return _YouTubeUrl().Matches(text).Select(m => m.Groups[1].Value).Distinct().ToList();
}
/// <summary>
/// Downloads into <paramref name="folder" /> as "<paramref name="baseName" />.ext" and returns the file name.
/// <paramref name="format" /> is a yt-dlp format selector.
/// </summary>
public async Task<string> DownloadAsync(string url, string folder, string baseName, string format, IProgress<double>? progress, CancellationToken ct)
{
var psi = new ProcessStartInfo(_Executable)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
WorkingDirectory = folder
};
foreach (var arg in new[] { "--no-playlist", "--newline", "--no-mtime", "-f", format, "-o", baseName + ".%(ext)s", "--", url })
{
psi.ArgumentList.Add(arg);
}
using var process = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp could not be started");
var errors = new List<string>();
var stderr = Task.Run(async () =>
{
while (await process.StandardError.ReadLineAsync(ct) is { } line)
{
errors.Add(line);
}
}, ct);
while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
{
var m = _ProgressLine().Match(line);
if (m.Success && double.TryParse(m.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
{
progress?.Report(percent / 100);
}
}
await process.WaitForExitAsync(ct);
await stderr;
if (process.ExitCode != 0)
{
var error = errors.LastOrDefault(e => e.Contains("ERROR")) ?? errors.LastOrDefault() ?? "exit code " + process.ExitCode;
_Logger.LogWarning("yt-dlp failed for {Url}: {Error}", url, error);
throw new InvalidOperationException("yt-dlp: " + error);
}
var file = Directory.EnumerateFiles(folder, baseName + ".*").Select(Path.GetFileName).FirstOrDefault(f => !f!.EndsWith(".part"));
return file ?? throw new InvalidOperationException("yt-dlp finished but no file was written");
}
}
}
+73
View File
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text;
using VocaluxeSongServer.Contracts;
namespace VocaluxeSongServer.Sources
{
/// <summary>A place where new songs can be found and downloaded (song database, song repository, ...).</summary>
public interface ISongSource
{
string Id { get; }
string Name { get; }
string Description { get; }
bool Enabled { get; }
/// <summary>Why the source is disabled, or empty.</summary>
string Hint { get; }
Task<List<SearchResultDto>> SearchAsync(string query, CancellationToken ct);
/// <summary>
/// Downloads the song into <paramref name="folder" /> (created by the caller, empty). Must leave a playable
/// UltraStar song there (text file + audio) or throw.
/// </summary>
Task DownloadAsync(string sourceId, string folder, IProgress<double> progress, CancellationToken ct);
}
public static class CSourceHelper
{
/// <summary>Lower case, without diacritics and punctuation, single spaces: for matching artists and titles.</summary>
public static string Normalize(string s)
{
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
{
var cat = CharUnicodeInfo.GetUnicodeCategory(c);
if (cat == UnicodeCategory.NonSpacingMark)
{
continue;
}
sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : ' ');
}
return string.Join(' ', sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>True if every word of the query occurs in the text (both normalized).</summary>
public static bool Matches(string normalizedQuery, string text)
{
var normalized = " " + Normalize(text) + " ";
return normalizedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries).All(w => normalized.Contains(w));
}
/// <summary>A file or folder name without characters that are invalid on Windows or Linux.</summary>
public static string SafeName(string name)
{
var invalid = "<>:\"/\\|?*".ToCharArray();
var cleaned = new string(name.Select(c => invalid.Contains(c) || char.IsControl(c) ? '_' : c).ToArray()).Trim().TrimEnd('.');
return cleaned.Length > 120 ? cleaned[..120] : cleaned;
}
/// <summary>Copies a stream and reports the bytes copied.</summary>
public static async Task CopyAsync(Stream source, Stream target, Action<long> onBytes, CancellationToken ct)
{
var buffer = new byte[81920];
int read;
while ((read = await source.ReadAsync(buffer, ct)) > 0)
{
await target.WriteAsync(buffer.AsMemory(0, read), ct);
onBytes(read);
}
}
}
}
+1
View File
@@ -0,0 +1 @@
0.1.0
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- The song server: hosts the song library (UltraStar folders) for Vocaluxe clients. Runs in Docker on Linux,
see README.md. Target framework and configurations come from ../Directory.Build.props. -->
<PropertyGroup>
<RootNamespace>VocaluxeSongServer</RootNamespace>
<AssemblyName>VocaluxeSongServer</AssemblyName>
<Platforms>AnyCPU</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<CodeAnalysisRuleSet></CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Tests" />
</ItemGroup>
</Project>
+20
View File
@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"Urls": "http://0.0.0.0:8080",
"Library": {
"Path": "/songs",
"ScanIntervalMinutes": 10,
"ApiKey": "",
"DownloadFolder": "Downloads"
},
"Usdb": {
"User": "",
"Password": ""
}
}
+306
View File
@@ -0,0 +1,306 @@
:root {
--bg: #f6f5fb;
--card: #ffffff;
--text: #1c1830;
--muted: #6b6785;
--line: #e4e1ef;
--accent: #7c3aed;
--accent-text: #ffffff;
--accent-soft: #ede7fd;
--ok: #15803d;
--ok-soft: #dcfce7;
--err: #b91c1c;
--err-soft: #fee2e2;
--radius: 14px;
--nav-h: 64px;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12101c;
--card: #1d1a2b;
--text: #eceaf6;
--muted: #a29dbd;
--line: #2e2a42;
--accent: #a78bfa;
--accent-text: #160f2c;
--accent-soft: #2c2350;
--ok: #4ade80;
--ok-soft: #14301f;
--err: #f87171;
--err-soft: #3a1717;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 16px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
padding-bottom: calc(var(--nav-h) + env(safe-area-inset-bottom));
}
.top {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: calc(10px + env(safe-area-inset-top)) 16px 10px;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(10px);
}
.top h1 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1.2rem;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 0 16px 16px;
}
.tab { display: none; }
.tab.active { display: block; }
.muted { color: var(--muted); font-size: .9rem; }
.searchbar { margin: 4px 0 10px; }
.searchbar input, dialog input {
width: 100%;
min-height: 48px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--card);
color: var(--text);
font-size: 1.05rem; /* >= 16px: no zoom on iOS */
outline: none;
}
.searchbar input:focus, dialog input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--text);
font-size: .8rem;
}
.chip.off { background: transparent; border: 1px dashed var(--line); color: var(--muted); }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
.chip.off .dot { background: var(--muted); }
.status { padding: 12px 4px; color: var(--muted); }
.status.error { color: var(--err); }
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.item {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--radius);
background: var(--card);
border: 1px solid var(--line);
}
.item .cover {
width: 56px;
height: 56px;
border-radius: 10px;
object-fit: cover;
background: var(--accent-soft);
display: grid;
place-items: center;
color: var(--accent);
font-weight: 700;
font-size: 1.2rem;
overflow: hidden;
}
.item .text { min-width: 0; }
.item .title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item .artist, .item .details {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item .artist { font-size: .92rem; }
.item .details { color: var(--muted); font-size: .8rem; }
.source {
display: inline-block;
margin-right: 4px;
padding: 0 6px;
border-radius: 6px;
background: var(--accent-soft);
color: var(--text);
font-size: .72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .02em;
}
button {
font: inherit;
cursor: pointer;
}
.action {
min-width: 44px;
min-height: 44px;
padding: 0 14px;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
.action:disabled { cursor: default; }
.action.done { background: var(--ok-soft); color: var(--ok); }
.action.pending { background: var(--accent-soft); color: var(--text); }
.progress {
grid-column: 1 / -1;
height: 6px;
border-radius: 999px;
background: var(--line);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
background: var(--accent);
transition: width .4s;
}
.state { font-size: .8rem; font-weight: 600; }
.state.done { color: var(--ok); }
.state.failed { color: var(--err); }
.message { grid-column: 1 / -1; color: var(--err); font-size: .85rem; overflow-wrap: anywhere; }
.compact .item { grid-template-columns: 1fr; padding: 10px 14px; }
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: grid;
grid-template-columns: repeat(3, 1fr);
height: calc(var(--nav-h) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--card);
border-top: 1px solid var(--line);
}
.tabs button {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border: 0;
background: none;
color: var(--muted);
font-size: .78rem;
}
.tabs button.active { color: var(--accent); font-weight: 600; }
.tabs svg {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.badge {
position: absolute;
top: 8px;
left: calc(50% + 6px);
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-text);
font-size: .7rem;
line-height: 18px;
}
dialog {
width: min(92vw, 400px);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--card);
color: var(--text);
}
dialog h2 { margin: 0 0 4px; font-size: 1.1rem; }
dialog menu { display: flex; justify-content: flex-end; margin: 12px 0 0; padding: 0; }
dialog::backdrop { background: rgb(0 0 0 / .4); }
@media (min-width: 720px) {
.tabs {
left: 50%;
right: auto;
width: 720px;
transform: translateX(-50%);
border: 1px solid var(--line);
border-bottom: 0;
border-radius: var(--radius) var(--radius) 0 0;
}
.tabs button { flex-direction: row; gap: 8px; font-size: .95rem; }
.badge { position: static; }
.top { max-width: 752px; margin: 0 auto; }
}
+309
View File
@@ -0,0 +1,309 @@
"use strict";
// Web UI of the Vocaluxe song server: search sources, queue downloads, browse the library.
// Plain JavaScript, no build step. API: /api/v1 (see README.md).
const $ = (sel) => document.querySelector(sel);
const state = {
tab: "search",
library: null, // LibraryDto
libraryVersion: "",
jobs: [], // DownloadJobDto[]
results: [],
searchSeq: 0,
};
function storageGet(key) {
try { return localStorage.getItem(key) || ""; } catch { return ""; }
}
function storageSet(key, value) {
try { localStorage.setItem(key, value); } catch { /* private mode */ }
}
// ---- API ------------------------------------------------------------------
async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
const key = storageGet("apiKey");
if (key) headers["X-Api-Key"] = key;
if (options.body) headers["Content-Type"] = "application/json";
const response = await fetch("api/v1/" + path, { ...options, headers });
if (response.status === 401) {
await askForKey();
return api(path, options);
}
if (!response.ok) {
let title = response.statusText;
try { title = (await response.json()).title || title; } catch { /* not JSON */ }
throw new Error(title);
}
return response.status === 204 ? null : response.json();
}
function askForKey() {
return new Promise((resolve) => {
const dialog = $("#keyDialog");
dialog.addEventListener("close", () => {
storageSet("apiKey", $("#keyInput").value.trim());
resolve();
}, { once: true });
dialog.showModal();
});
}
// ---- Rendering helpers ----------------------------------------------------
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else if (v !== false && v != null) node.setAttribute(k, v === true ? "" : v);
}
for (const child of children.flat()) {
if (child != null && child !== false) node.append(child);
}
return node;
}
function cover(url, title) {
const fallback = el("div", { class: "cover", "aria-hidden": "true" }, (title || "?").trim().charAt(0).toUpperCase());
if (!url) return fallback;
const img = el("img", { class: "cover", src: url, alt: "", loading: "lazy", referrerpolicy: "no-referrer" });
img.addEventListener("error", () => img.replaceWith(fallback), { once: true });
return img;
}
function songKey(artist, title) {
const norm = (s) => (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
return norm(artist) + "|" + norm(title);
}
// ---- Tabs -----------------------------------------------------------------
function showTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.id === "tab-" + tab));
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
storageSet("tab", tab);
if (tab === "library") loadLibrary();
if (tab === "downloads") renderJobs();
if (tab === "search") $("#query").focus({ preventScroll: true });
}
document.querySelectorAll(".tabs button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
// ---- Sources --------------------------------------------------------------
async function loadSources() {
try {
const sources = await api("sources");
$("#sources").replaceChildren(...sources.map((s) =>
el("span", { class: "chip" + (s.enabled && !s.hint ? "" : " off"), title: s.hint || s.description },
el("span", { class: "dot" }), s.name, s.hint && s.enabled ? " (limited)" : "", !s.enabled ? " (off)" : "")));
} catch (e) {
$("#sources").replaceChildren();
}
}
// ---- Search ---------------------------------------------------------------
let searchTimer = 0;
$("#query").addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(search, 450);
});
$("#searchForm").addEventListener("submit", (e) => {
e.preventDefault();
clearTimeout(searchTimer);
$("#query").blur(); // hide the keyboard on phones
search();
});
async function search() {
const q = $("#query").value.trim();
const seq = ++state.searchSeq;
const status = $("#searchStatus");
status.className = "status";
if (q.length < 2) {
state.results = [];
status.textContent = "Type at least two letters. All song sources are searched at once.";
renderResults();
return;
}
status.textContent = "Searching…";
try {
const response = await api("search?q=" + encodeURIComponent(q));
if (seq !== state.searchSeq) return; // a newer search is running
state.results = response.results;
const errors = Object.entries(response.errors || {}).map(([source, msg]) => `${source}: ${msg}`);
status.textContent = (response.results.length ? `${response.results.length} songs found` : "Nothing found") +
(errors.length ? ` · not searched: ${errors.join("; ")}` : "");
status.classList.toggle("error", errors.length > 0 && response.results.length === 0);
renderResults();
} catch (e) {
if (seq !== state.searchSeq) return;
status.textContent = "Search failed: " + e.message;
status.classList.add("error");
}
}
function jobFor(result) {
return state.jobs.find((j) => j.source === result.source && j.sourceId === result.sourceId);
}
function resultButton(result) {
const job = jobFor(result);
if (result.inLibrary || (job && job.state === "done")) {
return el("button", { class: "action done", disabled: true, "aria-label": "In the library" }, "✓");
}
if (job && (job.state === "queued" || job.state === "running")) {
return el("button", { class: "action pending", disabled: true }, Math.round(job.progress * 100) + "%");
}
return el("button", {
class: "action",
"aria-label": `Download ${result.artist} – ${result.title}`,
onclick: (e) => download(result, e.currentTarget),
}, job && job.state === "failed" ? "Retry" : "Get");
}
function renderResults() {
$("#results").replaceChildren(...state.results.map((r) =>
el("li", { class: "item", "data-key": r.source + "/" + r.sourceId },
cover(r.coverUrl, r.title),
el("div", { class: "text" },
el("div", { class: "title" }, r.title),
el("div", { class: "artist" }, r.artist),
el("div", { class: "details" }, el("span", { class: "source" }, r.source), r.details)),
resultButton(r))));
}
async function download(result, button) {
button.disabled = true;
button.textContent = "…";
try {
const job = await api("downloads", {
method: "POST",
body: JSON.stringify({ source: result.source, sourceId: result.sourceId, artist: result.artist, title: result.title }),
});
state.jobs = [job, ...state.jobs.filter((j) => j.id !== job.id)];
renderResults();
renderBadge();
pollJobs(true);
} catch (e) {
button.disabled = false;
button.textContent = "Get";
alert("Download failed: " + e.message);
}
}
// ---- Downloads ------------------------------------------------------------
let pollTimer = 0;
async function pollJobs(soon) {
clearTimeout(pollTimer);
if (soon) {
pollTimer = setTimeout(() => pollJobs(false), 800);
return;
}
try {
const before = state.jobs.filter((j) => j.state === "done").length;
state.jobs = await api("downloads");
renderJobs();
renderBadge();
if (state.results.length) renderResults();
if (state.jobs.filter((j) => j.state === "done").length !== before) {
state.libraryVersion = ""; // a song was added: reload the library when it's shown
setTimeout(loadLibraryCount, 3000);
}
} catch { /* try again later */ }
const active = state.jobs.some((j) => j.state === "queued" || j.state === "running");
pollTimer = setTimeout(() => pollJobs(false), active ? 1500 : 10000);
}
function renderBadge() {
const active = state.jobs.filter((j) => j.state === "queued" || j.state === "running").length;
const badge = $("#activeBadge");
badge.hidden = active === 0;
badge.textContent = active;
}
const stateText = { queued: "Waiting", running: "Downloading", done: "Done", failed: "Failed" };
function renderJobs() {
$("#downloadsEmpty").hidden = state.jobs.length > 0;
$("#downloads").replaceChildren(...state.jobs.map((j) =>
el("li", { class: "item" },
cover("", j.title),
el("div", { class: "text" },
el("div", { class: "title" }, j.title),
el("div", { class: "artist" }, j.artist),
el("div", { class: "details" }, el("span", { class: "source" }, j.source),
j.message && j.state === "done" ? j.message : "")),
el("span", { class: "state " + j.state }, stateText[j.state] || j.state),
j.state === "running" || j.state === "queued"
? el("div", { class: "progress", role: "progressbar", "aria-valuenow": Math.round(j.progress * 100), "aria-valuemin": 0, "aria-valuemax": 100 },
el("span", { style: `width:${Math.round(j.progress * 100)}%` }))
: null,
j.state === "failed" ? el("div", { class: "message" }, j.message) : null)));
}
// ---- Library --------------------------------------------------------------
async function loadLibraryCount() {
try {
const health = await fetch("health").then((r) => r.json());
if (health.songs != null) $("#libraryCount").textContent = health.songs + " songs";
} catch { /* offline */ }
}
async function loadLibrary() {
const status = $("#libraryStatus");
if (state.library && state.libraryVersion) {
renderLibrary();
return;
}
status.textContent = "Loading…";
try {
state.library = await api("library");
state.libraryVersion = state.library.version;
$("#libraryCount").textContent = state.library.songs.length + " songs";
renderLibrary();
} catch (e) {
status.textContent = "Library not available: " + e.message;
}
}
$("#libraryFilter").addEventListener("input", () => renderLibrary());
function renderLibrary() {
if (!state.library) return;
const words = songKey($("#libraryFilter").value, "").replace("|", "").split(" ").filter(Boolean);
const songs = state.library.songs
.filter((s) => { const k = songKey(s.artist, s.title).replace("|", " "); return words.every((w) => k.includes(w)); })
.sort((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title));
const shown = songs.slice(0, 300);
$("#libraryStatus").textContent = songs.length === 0
? (state.library.songs.length ? "No matching songs." : "The library is empty. Download songs under Search.")
: songs.length > shown.length ? `${songs.length} songs, showing the first ${shown.length}. Filter to narrow down.` : `${songs.length} songs`;
$("#library").replaceChildren(...shown.map((s) =>
el("li", { class: "item" },
el("div", { class: "text" }, el("div", { class: "title" }, s.title), el("div", { class: "artist" }, s.artist)))));
}
// ---- Start ----------------------------------------------------------------
// A link like "?q=queen" opens a search, "?tab=library" a tab.
const params = new URLSearchParams(location.search);
if (params.get("q")) $("#query").value = params.get("q");
const startTab = params.get("q") ? "search" : params.get("tab") || storageGet("tab");
loadSources();
loadLibraryCount();
pollJobs(false);
search();
showTab(["search", "downloads", "library"].includes(startTab) ? startTab : "search");
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#7c3aed"/><g fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><rect x="24" y="10" width="16" height="26" rx="8"/><path d="M17 30a15 15 0 0 0 30 0M32 45v9M24 54h16"/></g></svg>

After

Width:  |  Height:  |  Size: 320 B

+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#7c3aed">
<title>Karaoke Songs</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon.svg">
<link rel="stylesheet" href="app.css">
</head>
<body>
<header class="top">
<h1><img src="icon.svg" alt="" width="28" height="28"> Karaoke Songs</h1>
<span id="libraryCount" class="muted"></span>
</header>
<main>
<section id="tab-search" class="tab active" aria-label="Search">
<form id="searchForm" class="searchbar" role="search">
<input id="query" type="search" placeholder="Artist or title" autocomplete="off" enterkeyhint="search" aria-label="Search songs">
</form>
<div id="sources" class="chips"></div>
<div id="searchStatus" class="status"></div>
<ul id="results" class="list"></ul>
</section>
<section id="tab-downloads" class="tab" aria-label="Downloads">
<div id="downloadsEmpty" class="status">No downloads yet. Find songs under Search.</div>
<ul id="downloads" class="list"></ul>
</section>
<section id="tab-library" class="tab" aria-label="Library">
<form class="searchbar" onsubmit="return false">
<input id="libraryFilter" type="search" placeholder="Filter the library" autocomplete="off" aria-label="Filter the library">
</form>
<div id="libraryStatus" class="status"></div>
<ul id="library" class="list compact"></ul>
</section>
</main>
<nav class="tabs" aria-label="Sections">
<button data-tab="search" class="active">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg>
Search
</button>
<button data-tab="downloads">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11m0 0-4-4m4 4 4-4M5 20h14"/></svg>
Downloads <span id="activeBadge" class="badge" hidden></span>
</button>
<button data-tab="library">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l11-2v12"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>
Library
</button>
</nav>
<dialog id="keyDialog">
<form method="dialog">
<h2>Access key</h2>
<p class="muted">This song server needs an access key.</p>
<input id="keyInput" type="password" autocomplete="current-password" aria-label="Access key">
<menu><button value="ok" class="primary">Save</button></menu>
</form>
</dialog>
<script src="app.js"></script>
</body>
</html>
@@ -0,0 +1,9 @@
{
"name": "Karaoke Songs",
"short_name": "Karaoke",
"start_url": "./",
"display": "standalone",
"background_color": "#12101c",
"theme_color": "#7c3aed",
"icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
}