- ${
}${release class="d-none d-lg-inline text-truncate text-decoration-none link-success"}${}
+
+ ${}${release class="d-none d-lg-inline text-decoration-none link-success"}${}
diff --git a/conf/lms.conf b/conf/lms.conf
index e53559b8..7231c04b 100644
--- a/conf/lms.conf
+++ b/conf/lms.conf
@@ -84,14 +84,6 @@ login-throttler-max-entries = 10000;
# API
api-subsonic = true;
-# Enable or disable user/password authentication for the Subsonic API.
-# Note: Since token/salt authentication is always disabled, setting this to 'false' means only API keys can be used to access the Subsonic API.
-api-subsonic-support-user-password-auth = true;
-
-# Use this list to make the reported server version to 1.12.0 depending on the client's name
-# Main usage is to make auto detections for the 'p' (password) parameter work
-api-subsonic-old-server-protocol-clients = ("DSub");
-
# List of clients for whom open subsonic extensions and extra fields are disabled
api-open-subsonic-disabled-clients = ("DSub");
diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp
index b77290f9..97612035 100644
--- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp
+++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp
@@ -65,7 +65,7 @@ namespace lms::audio::ffmpeg
return planar ? AV_SAMPLE_FMT_DBLP : AV_SAMPLE_FMT_DBL;
}
- throw Exception("Unsupported PcmSampleType");
+ throw Exception{ "Unsupported PcmSampleType" };
}
SwrContextPtr createResampler(const PcmParameters& params, const AVChannelLayout& inLayout, AVSampleFormat inFmt, int inSampleRate)
@@ -104,7 +104,7 @@ namespace lms::audio::ffmpeg
: _parameters{ parameters }
{
if (_parameters.channelCount > AV_NUM_DATA_POINTERS)
- throw Exception("Channel count exceeds maximum supported channels");
+ throw Exception{ "Channel count exceeds maximum supported channels" };
utils::init();
@@ -179,7 +179,7 @@ namespace lms::audio::ffmpeg
{
int error{ ::avcodec_open2(_decoderContext.get(), decoder, nullptr) };
if (error < 0)
- throw FFmpegException("Cannot open decoder", error);
+ throw FFmpegException{ "Cannot open decoder", error };
}
_decodedFrame = AVFramePtr{ av_frame_alloc() };
diff --git a/src/libs/audio/impl/ffmpeg/TagReader.cpp b/src/libs/audio/impl/ffmpeg/TagReader.cpp
index dcf648aa..97095f7e 100644
--- a/src/libs/audio/impl/ffmpeg/TagReader.cpp
+++ b/src/libs/audio/impl/ffmpeg/TagReader.cpp
@@ -83,9 +83,9 @@ namespace lms::audio::ffmpeg
{ TagType::Mixers, { "MIXERS" } },
{ TagType::MixersSortOrder, { "MIXERSSORT" } },
{ TagType::Mood, { "MOOD" } },
- { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } },
- { TagType::MovementCount, { "MOVEMENTCOUNT" } },
- { TagType::MovementNumber, { "MOVEMENTNUMBER" } },
+ { TagType::Movement, { "MOVEMENTNAME" } },
+ { TagType::MovementCount, { "MOVEMENTCOUNT", "MOVEMENTTOTAL" } },
+ { TagType::MovementNumber, { "MOVEMENTNUMBER", "MOVEMENT" } },
{ TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } },
{ TagType::MusicBrainzArrangerID, { "MUSICBRAINZ_ARRANGERID", "MUSICBRAINZ ARRANGER ID", "MUSICBRAINZ/ARRANGER ID" } },
{ TagType::MusicBrainzComposerID, { "MUSICBRAINZ_COMPOSERID", "MUSICBRAINZ COMPOSER ID", "MUSICBRAINZ/COMPOSER ID" } },
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp
index 65b3cdb2..5c102558 100644
--- a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp
+++ b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp
@@ -89,13 +89,13 @@ namespace lms::audio::pulseaudio
{
const int error{ ::pa_context_connect(_context.get(), nullptr, PA_CONTEXT_NOFLAGS, nullptr) };
if (error < 0)
- throw PaException("pa_context_connect failed", error);
+ throw PaException{ "pa_context_connect failed", error };
}
{
const int error{ ::pa_threaded_mainloop_start(_mainLoop.get()) };
if (error < 0)
- throw PaException("pa_threaded_mainloop_start failed", error);
+ throw PaException{ "pa_threaded_mainloop_start failed", error };
}
}
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp
index 27832fbb..b8f357f4 100644
--- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp
+++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp
@@ -226,7 +226,7 @@ namespace lms::audio::pulseaudio
{
pa_operation* op{ ::pa_stream_flush(_stream.get(), nullptr, nullptr) };
if (!op)
- throw PaException("pa_stream_flush failed", pa_context_errno(_context));
+ throw PaException{ "pa_stream_flush failed", pa_context_errno(_context) };
::pa_operation_unref(op);
}
@@ -249,7 +249,7 @@ namespace lms::audio::pulseaudio
pa_operation* op{ ::pa_stream_cork(_stream.get(), 1, nullptr, nullptr) };
if (!op)
- throw PaException("pa_stream_cork (pause) failed", pa_context_errno(_context));
+ throw PaException{ "pa_stream_cork (pause) failed", pa_context_errno(_context) };
::pa_operation_unref(op);
}
@@ -263,14 +263,14 @@ namespace lms::audio::pulseaudio
{
pa_operation* op{ ::pa_stream_cork(_stream.get(), 0, nullptr, nullptr) };
if (!op)
- throw PaException("pa_stream_cork (resume) failed", pa_context_errno(_context));
+ throw PaException{ "pa_stream_cork (resume) failed", pa_context_errno(_context) };
::pa_operation_unref(op);
}
{
pa_operation* op{ ::pa_stream_trigger(_stream.get(), NULL, NULL) };
if (!op)
- throw PaException("pa_stream_trigger failed", pa_context_errno(_context));
+ throw PaException{ "pa_stream_trigger failed", pa_context_errno(_context) };
::pa_operation_unref(op);
}
}
@@ -301,7 +301,7 @@ namespace lms::audio::pulseaudio
nullptr,
nullptr) };
if (!op)
- throw PaException("pa_context_set_sink_input_volume failed", pa_context_errno(_context));
+ throw PaException{ "pa_context_set_sink_input_volume failed", pa_context_errno(_context) };
::pa_operation_unref(op);
@@ -425,7 +425,7 @@ namespace lms::audio::pulseaudio
::pa_operation* op{ ::pa_stream_drain(_stream.get(), [](pa_stream*, int success, void* userdata) { static_cast
(userdata)->onDrainComplete(success); }, this) };
if (!op)
- throw PaException("pa_stream_drain failed", pa_context_errno(_context));
+ throw PaException{ "pa_stream_drain failed", pa_context_errno(_context) };
::pa_operation_unref(op);
}
diff --git a/src/libs/audio/impl/taglib/TagReader.cpp b/src/libs/audio/impl/taglib/TagReader.cpp
index a905f7b5..cd3f594b 100644
--- a/src/libs/audio/impl/taglib/TagReader.cpp
+++ b/src/libs/audio/impl/taglib/TagReader.cpp
@@ -113,9 +113,9 @@ namespace lms::audio::taglib
{ TagType::Mixers, { "MIXERS" } },
{ TagType::MixersSortOrder, { "MIXERSSORT" } },
{ TagType::Mood, { "MOOD" } },
- { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } },
- { TagType::MovementCount, { "MOVEMENTCOUNT" } },
- { TagType::MovementNumber, { "MOVEMENTNUMBER" } },
+ { TagType::Movement, { "MOVEMENTNAME" } },
+ { TagType::MovementCount, { "MOVEMENTCOUNT", "MOVEMENTTOTAL" } },
+ { TagType::MovementNumber, { "MOVEMENTNUMBER", "MOVEMENT" } },
{ TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } },
{ TagType::MusicBrainzArrangerID, { "MUSICBRAINZ_ARRANGERID", "MUSICBRAINZ ARRANGER ID", "MUSICBRAINZ/ARRANGER ID" } },
{ TagType::MusicBrainzComposerID, { "MUSICBRAINZ_COMPOSERID", "MUSICBRAINZ COMPOSER ID", "MUSICBRAINZ/COMPOSER ID" } },
diff --git a/src/libs/core/CMakeLists.txt b/src/libs/core/CMakeLists.txt
index 65c8826d..5b089608 100644
--- a/src/libs/core/CMakeLists.txt
+++ b/src/libs/core/CMakeLists.txt
@@ -13,6 +13,7 @@ configure_file(
add_library(lmscore STATIC
impl/http/Client.cpp
impl/http/SendQueue.cpp
+ impl/http/UrlValidation.cpp
impl/media/Codec.cpp
impl/media/Container.cpp
impl/media/ImageType.cpp
diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp
index 8fec0efb..a74e8bfe 100644
--- a/src/libs/core/impl/String.cpp
+++ b/src/libs/core/impl/String.cpp
@@ -341,6 +341,15 @@ namespace lms::core::stringUtils
return str.substr(0, str.find_last_not_of(whitespaces) + 1);
}
+ std::string_view utf8Truncate(std::string_view str, std::size_t maxBytes)
+ {
+ std::size_t len{ std::min(maxBytes, str.size()) };
+ while (len > 0 && len < str.size() && (static_cast(str[len]) & 0xC0) == 0x80)
+ --len;
+
+ return str.substr(0, len);
+ }
+
std::string stringToLower(std::string_view str)
{
std::string res;
@@ -366,18 +375,6 @@ namespace lms::core::stringUtils
return res;
}
- std::string bufferToString(std::span data)
- {
- std::ostringstream oss;
-
- for (unsigned char c : data)
- {
- oss << std::setw(2) << std::setfill('0') << std::hex << (int)c;
- }
-
- return oss.str();
- }
-
bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB)
{
if (strA.size() != strB.size())
@@ -418,6 +415,44 @@ namespace lms::core::stringUtils
}
}
+ std::string toRomanNumeral(std::size_t n)
+ {
+ if (n == 0 || n > 3999)
+ return {};
+
+ static constexpr struct
+ {
+ std::size_t val;
+ const char* sym;
+ } table[]{
+ { 1000, "m" },
+ { 900, "cm" },
+ { 500, "d" },
+ { 400, "cd" },
+ { 100, "c" },
+ { 90, "xc" },
+ { 50, "l" },
+ { 40, "xl" },
+ { 10, "x" },
+ { 9, "ix" },
+ { 5, "v" },
+ { 4, "iv" },
+ { 1, "i" }
+ };
+
+ std::string res;
+ for (const auto& [val, sym] : table)
+ {
+ while (n >= val)
+ {
+ res += sym;
+ n -= val;
+ }
+ }
+
+ return res;
+ }
+
std::string replaceInString(std::string_view str, std::string_view from, std::string_view to)
{
std::string res{ str };
@@ -561,16 +596,18 @@ namespace lms::core::stringUtils
return res;
}
- std::string toHexString(std::string_view str)
+ std::string bufferToHexString(std::span data)
{
constexpr char lut[]{ "0123456789ABCDEF" };
std::string res;
+ res.reserve(data.size() * 2);
- for (char c : str)
+ for (const std::byte b : data)
{
- res.push_back(lut[(c >> 4) & 0xF]);
- res.push_back(lut[c & 0xF]);
+ const unsigned value{ std::to_integer(b) };
+ res.push_back(lut[(value >> 4) & 0xF]);
+ res.push_back(lut[value & 0xF]);
}
return res;
diff --git a/src/libs/core/impl/http/SendQueue.cpp b/src/libs/core/impl/http/SendQueue.cpp
index e4c819ec..33b2eac3 100644
--- a/src/libs/core/impl/http/SendQueue.cpp
+++ b/src/libs/core/impl/http/SendQueue.cpp
@@ -31,6 +31,7 @@
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/String.hpp"
+#include "core/http/UrlValidation.hpp"
#define LOG(sev, message) LMS_LOG(HTTP, sev, "[Http SendQueue] - " << message)
@@ -190,6 +191,12 @@ namespace lms::core::http
const std::string url{ _baseUrl + request.getParameters().relativeUrl };
LOG(DEBUG, "Sending " << (request.getType() == ClientRequest::Type::GET ? "GET" : "POST") << " request to url '" << url << "'");
+ if (!isValidUrl(url))
+ {
+ LOG(ERROR, "Refusing request to '" << url << "': invalid URL");
+ return false;
+ }
+
_client.setMaximumResponseSize(request.getParameters().onChunkReceived ? 0 : request.getParameters().responseBufferSize);
bool res{};
diff --git a/src/libs/core/impl/http/UrlValidation.cpp b/src/libs/core/impl/http/UrlValidation.cpp
new file mode 100644
index 00000000..d6269785
--- /dev/null
+++ b/src/libs/core/impl/http/UrlValidation.cpp
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS 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.
+ *
+ * LMS 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 LMS. If not, see .
+ */
+
+#include "core/http/UrlValidation.hpp"
+
+namespace lms::core::http
+{
+ bool isValidUrl(std::string_view url)
+ {
+ return !url.empty() && (url.starts_with("http://") || url.starts_with("https://"));
+ }
+} // namespace lms::core::http
diff --git a/src/libs/core/impl/media/Codec.cpp b/src/libs/core/impl/media/Codec.cpp
index 22eaf602..83eb91e6 100644
--- a/src/libs/core/impl/media/Codec.cpp
+++ b/src/libs/core/impl/media/Codec.cpp
@@ -40,7 +40,7 @@ namespace lms::core::media
CodecDesc{ Codec::MPC7, "MPC7", "Musepack7", false },
CodecDesc{ Codec::MPC8, "MPC8", "Musepack8", false },
CodecDesc{ Codec::Opus, "Opus", "Opus", false },
- CodecDesc{ Codec::PCM, "PCM", "Pulse-code modulation", false },
+ CodecDesc{ Codec::PCM, "PCM", "Pulse-code modulation", true },
CodecDesc{ Codec::Shorten, "Shorten", "Shorten", true },
CodecDesc{ Codec::TrueAudio, "TTA", "The True Audio", true },
CodecDesc{ Codec::Vorbis, "Vorbis", "Vorbis", false },
diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp
index 2dd2652c..3562acbb 100644
--- a/src/libs/core/include/core/String.hpp
+++ b/src/libs/core/include/core/String.hpp
@@ -20,6 +20,7 @@
#pragma once
#include
+#include
#include
#include
#include
@@ -54,17 +55,21 @@ namespace lms::core::stringUtils
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t\r");
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t\r");
+ // Like str.substr(0, maxBytes), but takes utf8 into account to properly truncate
+ [[nodiscard]] std::string_view utf8Truncate(std::string_view str, std::size_t maxBytes);
+
[[nodiscard]] std::string stringToLower(std::string_view str);
void stringToLower(std::string& str);
[[nodiscard]] std::string stringToUpper(const std::string& str);
- [[nodiscard]] std::string bufferToString(std::span data);
-
[[nodiscard]] bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB);
[[nodiscard]] std::string_view::size_type stringCaseInsensitiveContains(std::string_view str, std::string_view strtoFind);
void capitalize(std::string& str);
+ // returns empty string if invalid input
+ [[nodiscard]] std::string toRomanNumeral(std::size_t n);
+
template
[[nodiscard]] std::optional readAs(std::string_view str)
{
@@ -116,7 +121,7 @@ namespace lms::core::stringUtils
[[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending);
[[nodiscard]] std::optional stringFromHex(std::string_view str);
- [[nodiscard]] std::string toHexString(std::string_view str);
+ [[nodiscard]] std::string bufferToHexString(std::span data);
[[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime);
[[nodiscard]] std::string toISO8601String(const Wt::WDate& date);
diff --git a/src/libs/core/include/core/http/UrlValidation.hpp b/src/libs/core/include/core/http/UrlValidation.hpp
new file mode 100644
index 00000000..61dadee1
--- /dev/null
+++ b/src/libs/core/include/core/http/UrlValidation.hpp
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS 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.
+ *
+ * LMS 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 LMS. If not, see .
+ */
+
+#pragma once
+
+#include
+
+namespace lms::core::http
+{
+ // Returns true if url is a well-formed absolute URL with an http:// or https:// scheme.
+ bool isValidUrl(std::string_view url);
+} // namespace lms::core::http
diff --git a/src/libs/core/test/CMakeLists.txt b/src/libs/core/test/CMakeLists.txt
index 074abeb0..73ce60d2 100644
--- a/src/libs/core/test/CMakeLists.txt
+++ b/src/libs/core/test/CMakeLists.txt
@@ -2,6 +2,7 @@ include(GoogleTest)
add_executable(test-core
EnumSet.cpp
+ HttpUrlValidation.cpp
JobScheduler.cpp
LiteralString.cpp
PartialDateTime.cpp
diff --git a/src/libs/core/test/HttpUrlValidation.cpp b/src/libs/core/test/HttpUrlValidation.cpp
new file mode 100644
index 00000000..aa24df99
--- /dev/null
+++ b/src/libs/core/test/HttpUrlValidation.cpp
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS 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.
+ *
+ * LMS 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 LMS. If not, see .
+ */
+
+#include
+
+#include "core/http/UrlValidation.hpp"
+
+namespace lms::core::http::tests
+{
+ TEST(HttpUrlValidation, AllowedUrls)
+ {
+ EXPECT_TRUE(isValidUrl("http://feeds.example.com/podcast.rss"));
+ EXPECT_TRUE(isValidUrl("https://feeds.example.com/podcast.rss"));
+ EXPECT_TRUE(isValidUrl("http://192.168.1.100/feed.rss"));
+ EXPECT_TRUE(isValidUrl("https://example.com/episode.mp3"));
+ }
+
+ TEST(HttpUrlValidation, DisallowedUrls)
+ {
+ EXPECT_FALSE(isValidUrl(""));
+ EXPECT_FALSE(isValidUrl("ftp://attacker.com/feed"));
+ EXPECT_FALSE(isValidUrl("file:///etc/passwd"));
+ EXPECT_FALSE(isValidUrl("javascript:alert(1)"));
+ EXPECT_FALSE(isValidUrl("//example.com/feed"));
+ EXPECT_FALSE(isValidUrl("HTTP://example.com/feed")); // scheme check is case-sensitive
+ }
+} // namespace lms::core::http::tests
diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp
index 29b1d259..66da6c27 100644
--- a/src/libs/core/test/String.cpp
+++ b/src/libs/core/test/String.cpp
@@ -17,6 +17,9 @@
* along with LMS. If not, see .
*/
+#include
+#include
+
#include
#include
@@ -385,6 +388,31 @@ namespace lms::core::stringUtils::tests
EXPECT_FALSE(stringEndsWith("FooBar", "R"));
}
+ TEST(StringUtils, utf8Truncate)
+ {
+ EXPECT_EQ(utf8Truncate("abc", 10), "abc");
+ EXPECT_EQ(utf8Truncate("abcdef", 6), "abcdef");
+ EXPECT_EQ(utf8Truncate("", 10), "");
+ EXPECT_EQ(utf8Truncate("abc", 0), "");
+
+ EXPECT_EQ(utf8Truncate("caf\xC3\xA9", 4), "caf");
+ EXPECT_EQ(utf8Truncate("caf\xC3\xA9", 5), "caf\xC3\xA9");
+
+ EXPECT_EQ(utf8Truncate("\xE2\x82\xAC", 1), "");
+ EXPECT_EQ(utf8Truncate("\xE2\x82\xAC", 2), "");
+ EXPECT_EQ(utf8Truncate("\xE2\x82\xAC", 3), "\xE2\x82\xAC");
+
+ EXPECT_EQ(utf8Truncate("\xF0\x9F\x98\x80", 1), "");
+ EXPECT_EQ(utf8Truncate("\xF0\x9F\x98\x80", 2), "");
+ EXPECT_EQ(utf8Truncate("\xF0\x9F\x98\x80", 3), "");
+ EXPECT_EQ(utf8Truncate("\xF0\x9F\x98\x80", 4), "\xF0\x9F\x98\x80");
+
+ EXPECT_EQ(utf8Truncate("e\xCC\x81", 2), "e");
+ EXPECT_EQ(utf8Truncate("e\xCC\x81", 3), "e\xCC\x81");
+
+ EXPECT_EQ(utf8Truncate("\x80\x80\x80", 2), "");
+ }
+
TEST(StringUtils, stringCaseInsensitiveContains)
{
EXPECT_TRUE(stringCaseInsensitiveContains("FooBar", "Bar"));
@@ -397,13 +425,11 @@ namespace lms::core::stringUtils::tests
EXPECT_FALSE(stringCaseInsensitiveContains("", "Foo"));
}
- TEST(StringUtils, toHexString)
+ TEST(StringUtils, bufferToHexString)
{
- EXPECT_EQ(toHexString(""), "");
- EXPECT_EQ(toHexString("123"), "313233");
- EXPECT_EQ(toHexString("1234"), "31323334");
- EXPECT_EQ(toHexString("12345"), "3132333435");
- EXPECT_EQ(toHexString("Test"), "54657374");
+ EXPECT_EQ(bufferToHexString({}), "");
+ EXPECT_EQ(bufferToHexString(std::array{ std::byte{ 0x31 }, std::byte{ 0x32 }, std::byte{ 0x33 } }), "313233");
+ EXPECT_EQ(bufferToHexString(std::array{ std::byte{ 0x00 }, std::byte{ 0xab }, std::byte{ 0xcd }, std::byte{ 0xff } }), "00ABCDFF");
// test back stringFromHex
EXPECT_EQ(stringFromHex(""), "");
@@ -412,4 +438,32 @@ namespace lms::core::stringUtils::tests
EXPECT_EQ(stringFromHex("3132333435"), "12345");
EXPECT_EQ(stringFromHex("54657374"), "Test");
}
+
+ TEST(StringUtils, toRomanNumeral)
+ {
+ EXPECT_EQ(toRomanNumeral(1), "i");
+ EXPECT_EQ(toRomanNumeral(2), "ii");
+ EXPECT_EQ(toRomanNumeral(3), "iii");
+ EXPECT_EQ(toRomanNumeral(4), "iv");
+ EXPECT_EQ(toRomanNumeral(5), "v");
+ EXPECT_EQ(toRomanNumeral(6), "vi");
+ EXPECT_EQ(toRomanNumeral(7), "vii");
+ EXPECT_EQ(toRomanNumeral(8), "viii");
+ EXPECT_EQ(toRomanNumeral(9), "ix");
+ EXPECT_EQ(toRomanNumeral(10), "x");
+ EXPECT_EQ(toRomanNumeral(11), "xi");
+ EXPECT_EQ(toRomanNumeral(14), "xiv");
+ EXPECT_EQ(toRomanNumeral(16), "xvi");
+ EXPECT_EQ(toRomanNumeral(40), "xl");
+ EXPECT_EQ(toRomanNumeral(50), "l");
+ EXPECT_EQ(toRomanNumeral(90), "xc");
+ EXPECT_EQ(toRomanNumeral(99), "xcix");
+ EXPECT_EQ(toRomanNumeral(444), "cdxliv");
+ EXPECT_EQ(toRomanNumeral(1994), "mcmxciv");
+ EXPECT_EQ(toRomanNumeral(3999), "mmmcmxcix");
+ EXPECT_EQ(toRomanNumeral(0), "");
+ EXPECT_EQ(toRomanNumeral(4000), "");
+ EXPECT_EQ(toRomanNumeral(static_cast(-1)), ""); // underflows to SIZE_MAX
+ EXPECT_EQ(toRomanNumeral(std::numeric_limits::max()), "");
+ }
} // namespace lms::core::stringUtils::tests
\ No newline at end of file
diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt
index bc164a03..6360c809 100644
--- a/src/libs/database/CMakeLists.txt
+++ b/src/libs/database/CMakeLists.txt
@@ -24,11 +24,14 @@ add_library(lmsdatabase STATIC
impl/objects/RatedTrack.cpp
impl/objects/Release.cpp
impl/objects/ScanSettings.cpp
+ impl/objects/ServerInfo.cpp
impl/objects/StarredArtist.cpp
impl/objects/StarredRelease.cpp
impl/objects/StarredTrack.cpp
+ impl/objects/Movement.cpp
impl/objects/Track.cpp
impl/objects/TrackArtistLink.cpp
+ impl/objects/Work.cpp
impl/objects/TrackBookmark.cpp
impl/objects/TrackEmbeddedImage.cpp
impl/objects/TrackEmbeddedImageLink.cpp
diff --git a/src/libs/database/impl/Db.cpp b/src/libs/database/impl/Db.cpp
index 6d242777..6779b70c 100644
--- a/src/libs/database/impl/Db.cpp
+++ b/src/libs/database/impl/Db.cpp
@@ -248,7 +248,7 @@ namespace lms::db
}
else if (checkType != "none")
{
- throw Exception("Invalid 'db-integrity-check' value: '" + checkType + "'. Expected 'quick', 'full' or 'none'.");
+ throw Exception{ "Invalid 'db-integrity-check' value: '" + checkType + "'. Expected 'quick', 'full' or 'none'." };
}
}
@@ -360,7 +360,7 @@ namespace lms::db
}) };
if (!foreignKeyConstraintsPassed)
- throw Exception("Foreign key constraints check failed! Please restore from a backup or recreate the database.");
+ throw Exception{ "Foreign key constraints check failed! Please restore from a backup or recreate the database." };
LMS_LOG(DB, INFO, "Foreign key constraints check passed!");
}
diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp
index dc65b75d..eec0d484 100644
--- a/src/libs/database/impl/Migration.cpp
+++ b/src/libs/database/impl/Migration.cpp
@@ -36,7 +36,7 @@ namespace lms::db
{
namespace
{
- static constexpr Version LMS_DATABASE_VERSION{ 108 };
+ static constexpr Version LMS_DATABASE_VERSION{ 110 };
}
VersionInfo::VersionInfo()
@@ -1922,6 +1922,50 @@ WHERE ct.name = 'GROUPING')");
utils::executeCommand(*session.getDboSession(), R"(DELETE FROM cluster_type WHERE name IN ('GENRE', 'MOOD', 'LANGUAGE', 'GROUPING'))");
}
+ void migrateFromV108(Session& session)
+ {
+ utils::executeCommand(*session.getDboSession(), R"(
+CREATE TABLE IF NOT EXISTS "work" (
+ "id" integer primary key autoincrement,
+ "version" integer not null,
+ "name" text not null,
+ "mbid" blob
+))");
+ utils::executeCommand(*session.getDboSession(), R"(
+CREATE TABLE IF NOT EXISTS "track_work" (
+ "work_id" bigint,
+ "track_id" bigint,
+ primary key ("work_id", "track_id"),
+ constraint "fk_track_work_key1" foreign key ("work_id") references "work" ("id") on delete cascade deferrable initially deferred,
+ constraint "fk_track_work_key2" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred
+))");
+ utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "track_work_work" on "track_work" ("work_id"))");
+ utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "track_work_track" on "track_work" ("track_id"))");
+ utils::executeCommand(*session.getDboSession(), R"(
+CREATE TABLE IF NOT EXISTS "track_movement" (
+ "id" integer primary key autoincrement,
+ "version" integer not null,
+ "name" text not null,
+ "number" integer,
+ "count" integer,
+ "track_id" bigint,
+ constraint "fk_track_movement_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred
+))");
+
+ // Just increment the scan version of the settings to make the next scan rescan all audio files
+ utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1");
+ }
+
+ void migrateFromV109(Session& session)
+ {
+ utils::executeCommand(*session.getDboSession(), R"(
+CREATE TABLE IF NOT EXISTS "server_info" (
+ "id" integer primary key autoincrement,
+ "version" integer not null,
+ "instance_id" blob not null
+))");
+ }
+
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -2006,6 +2050,8 @@ WHERE ct.name = 'GROUPING')");
{ 105, migrateFromV105 },
{ 106, migrateFromV106 },
{ 107, migrateFromV107 },
+ { 108, migrateFromV108 },
+ { 109, migrateFromV109 },
};
LMS_SCOPED_TRACE_OVERVIEW("Database", "Migration");
diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp
index 01c49b2b..a72f6565 100644
--- a/src/libs/database/impl/Session.cpp
+++ b/src/libs/database/impl/Session.cpp
@@ -38,6 +38,7 @@
#include "database/objects/MediaLibrary.hpp"
#include "database/objects/Medium.hpp"
#include "database/objects/Mood.hpp"
+#include "database/objects/Movement.hpp"
#include "database/objects/PlayListFile.hpp"
#include "database/objects/PlayQueue.hpp"
#include "database/objects/Podcast.hpp"
@@ -48,6 +49,7 @@
#include "database/objects/Release.hpp"
#include "database/objects/ReleaseArtistLink.hpp"
#include "database/objects/ScanSettings.hpp"
+#include "database/objects/ServerInfo.hpp"
#include "database/objects/StarredArtist.hpp"
#include "database/objects/StarredRelease.hpp"
#include "database/objects/StarredTrack.hpp"
@@ -61,6 +63,7 @@
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include "database/objects/UIState.hpp"
#include "database/objects/User.hpp"
+#include "database/objects/Work.hpp"
#include "Db.hpp"
#include "Migration.hpp"
@@ -111,6 +114,7 @@ namespace lms::db
_session.mapClass("starred_release");
_session.mapClass("starred_track");
_session.mapClass