Compare commits

...

23 Commits

Author SHA1 Message Date
Simon Diesenreiter
dbd83ebb6a feat: basic initial implementation of spotify client link validator and song submission form refs: NOISSUE
Some checks failed
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m3s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
SonarQube Scan / SonarQube Trigger (push) Failing after 4m47s
2025-05-30 22:51:44 +02:00
Simon Diesenreiter
010316aa70 release: version 0.3.3 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 18s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m6s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 2m5s
SonarQube Scan / SonarQube Trigger (push) Failing after 4m45s
2025-05-26 09:23:29 +02:00
Simon Diesenreiter
d416242712 fix: save DateTime as UTC, refs NOISSUE 2025-05-26 09:23:25 +02:00
e9a824c6ef release: version 0.3.2 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 30s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m28s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 2m48s
SonarQube Scan / SonarQube Trigger (push) Failing after 4m55s
2025-05-25 21:36:50 +02:00
e0f206bc0c fix: exception thrown on LastOrDefault(), refs NOISSUE 2025-05-25 21:36:34 +02:00
183309e1ed release: version 0.3.1 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 21s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m22s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 3m13s
SonarQube Scan / SonarQube Trigger (push) Failing after 4m54s
2025-05-24 19:20:17 +02:00
a6321324f7 fix: fix build errors, refs NOISSUE 2025-05-24 19:20:12 +02:00
e400249284 release: version 0.3.0 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 24s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m3s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Failing after 1m48s
SonarQube Scan / SonarQube Trigger (push) Failing after 4m56s
2025-05-24 18:56:45 +02:00
73c5e40e1d ci: more CI fixes, refs NOISSUE 2025-05-24 18:56:37 +02:00
f728c88853 ci: improve commit message generation script, refs NOISSUE 2025-05-24 18:51:58 +02:00
5fdd6ec1d0 feat: keep track of user submissions, refs #4 2025-05-24 18:51:58 +02:00
fbb6d1a409 feat: save submission history, refs #7 2025-05-24 18:51:50 +02:00
Simon Diesenreiter
d9da54653e release: version 0.2.6 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 1m17s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m20s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 3m32s
SonarQube Scan / SonarQube Trigger (push) Failing after 5m7s
2025-05-22 14:28:36 +02:00
Simon Diesenreiter
859e96a706 fix: bugfixes, refs NOISSUE 2025-05-22 14:28:23 +02:00
0135b89f01 reduce number of emitted logs
Some checks failed
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m5s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
SonarQube Scan / SonarQube Trigger (push) Failing after 4m47s
2025-05-21 04:33:24 -07:00
8c1bbc9866 release: version 0.2.5 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 24s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m11s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 2m16s
SonarQube Scan / SonarQube Trigger (push) Failing after 4m52s
2025-05-18 11:59:55 +02:00
8100998732 fix: install dependencies in runtime container not only build container, refs NOISSUE 2025-05-18 11:59:52 +02:00
4a77a0d33a release: version 0.2.4 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 35s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m36s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Successful in 2m37s
SonarQube Scan / SonarQube Trigger (push) Failing after 5m5s
2025-05-18 11:44:06 +02:00
3acd4ad9d9 fix: build errors, refs NOISSUE 2025-05-18 11:44:01 +02:00
33aae65647 release: version 0.2.3 🚀
Some checks failed
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m29s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / Create Release (push) Successful in 1m18s
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
Build Docker image / deploy (push) Has been cancelled
2025-05-18 11:42:19 +02:00
083038d76c fix: resolve linting errors, refs NOISSUE 2025-05-18 11:42:09 +02:00
055cf79354 release: version 0.2.2 🚀
Some checks failed
Build Docker image / Create Release (push) Successful in 18s
CI / linter (9.0.X, ubuntu-latest) (push) Failing after 1m3s
CI / tests_linux (9.0.X, ubuntu-latest) (push) Has been skipped
Build Docker image / deploy (push) Failing after 2m0s
SonarQube Scan / SonarQube Trigger (push) Has been cancelled
2025-05-18 11:39:32 +02:00
b4a893d936 fix: also send pick suggestion to the group, refs NOISSUE 2025-05-18 11:39:27 +02:00
37 changed files with 1126 additions and 68 deletions

View File

@@ -46,7 +46,7 @@ create_file() {
}
get_commit_range() {
rm $TEMP_FILE_PATH/messages.txt
rm -f $TEMP_FILE_PATH/messages.txt
if [[ $LAST_TAG =~ $PATTERN ]]; then
create_file true
else
@@ -64,16 +64,16 @@ start() {
while read message; do
echo $message
if echo $message | grep -Pq '(feat|style)(\([\w]+\))?!:([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(NOISSUE)))'; then
if echo $message | grep -Pq '(feat|style)(\([\w]+\))?!:([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(#[0-9]+)|(NOISSUE)))'; then
increment_type="major"
echo "a"
break
elif echo $message | grep -Pq '(feat|style)(\([\w]+\))?:([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(NOISSUE)))'; then
elif echo $message | grep -Pq '(feat|style)(\([\w]+\))?:([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(#[0-9]+)|(NOISSUE)))'; then
if [ -z "$increment_type" ] || [ "$increment_type" == "patch" ]; then
increment_type="minor"
echo "b"
fi
elif echo $message | grep -Pq '(build|fix|perf|refactor|revert)(\(.+\))?:\s([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(NOISSUE)))'; then
elif echo $message | grep -Pq '(build|fix|perf|refactor|revert)(\(.+\))?:\s([a-zA-Z0-9-_!\&\.\%\(\)\=\w\s]+)\s?(,?\s?)((ref(s?):?\s?)(([A-Z0-9]+\-[0-9]+)|(#[0-9]+)|(NOISSUE)))'; then
if [ -z "$increment_type" ]; then
increment_type="patch"
echo "c"

View File

@@ -14,5 +14,6 @@ RUN dotnet publish ./song_of_the_day/song_of_the_day.csproj -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /App
RUN apt update && apt install libldap-2.5-0 -y
COPY --from=build /App/out .
ENTRYPOINT ["dotnet", "song_of_the_day.dll"]

View File

@@ -5,10 +5,111 @@ Changelog
(unreleased)
------------
Fix
~~~
- Save DateTime as UTC, refs NOISSUE. [Simon Diesenreiter]
0.3.2 (2025-05-25)
------------------
Fix
~~~
- Exception thrown on LastOrDefault(), refs NOISSUE. [Simon
Diesenreiter]
Other
~~~~~
0.3.1 (2025-05-24)
------------------
Fix
~~~
- Fix build errors, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.3.0 (2025-05-24)
------------------
- Ci: more CI fixes, refs NOISSUE. [Simon Diesenreiter]
- Ci: improve commit message generation script, refs NOISSUE. [Simon
Diesenreiter]
- Feat: keep track of user submissions, refs #4. [Simon Diesenreiter]
- Feat: save submission history, refs #7. [Simon Diesenreiter]
0.2.6 (2025-05-22)
------------------
Fix
~~~
- Bugfixes, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
- Reduce number of emitted logs. [simon]
0.2.5 (2025-05-18)
------------------
Fix
~~~
- Install dependencies in runtime container not only build container,
refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.2.4 (2025-05-18)
------------------
Fix
~~~
- Build errors, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.2.3 (2025-05-18)
------------------
Fix
~~~
- Resolve linting errors, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.2.2 (2025-05-18)
------------------
Fix
~~~
- Also send pick suggestion to the group, refs NOISSUE. [Simon
Diesenreiter]
Other
~~~~~
0.2.1 (2025-05-17)
------------------
Fix
~~~
- Fix Docker build, refs NOISSUE. [Simon Diesenreiter]
Other
~~~~~
0.2.0 (2025-05-17)
------------------
@@ -155,6 +256,10 @@ Other
0.1.9 (2025-04-15)
------------------
0.1.8 (2025-04-15)
------------------
Fix
~~~
- Additional debug outputs refs NOISSUE. [Simon Diesenreiter]

View File

@@ -0,0 +1,4 @@
[*.cs]
# CS8981: The type name only contains lower-cased ascii characters. Such names may become reserved for the language.
dotnet_diagnostic.CS8981.severity = none

View File

@@ -1,4 +1,4 @@
public interface IAuthenticationService
{
bool Authenticate(string userName, string password);
public interface IAuthenticationService
{
bool Authenticate(string userName, string password);
}

View File

@@ -10,6 +10,6 @@ public class LdapAuthenticationService : IAuthenticationService
public bool Authenticate(string username, string password)
{
var ldapInstance = LdapIntegration.Instance;
return ldapInstance.TestLogin(username, password);
return ldapInstance == null ? false : ldapInstance.TestLogin(username, password);
}
}

View File

@@ -18,7 +18,7 @@ public class PhoneClaimCodeProviderService
.Select(s => s[random.Next(s.Length)]).ToArray());
}
public void GenerateClaimCodeForUserAndNumber(string username, string phoneNumber)
public async void GenerateClaimCodeForUserAndNumber(string username, string phoneNumber)
{
var generatedCode = string.Empty;
if (IsCodeGeneratedForUser(username))
@@ -32,9 +32,9 @@ public class PhoneClaimCodeProviderService
_phoneClaimNumbers[username] = phoneNumber;
}
SignalIntegration.Instance.SendMessageToUserAsync("Your phone number validation code is: " + generatedCode, phoneNumber);
await SignalIntegration.Instance.SendMessageToUserAsync("Your phone number validation code is: " + generatedCode, phoneNumber);
}
public string ValidateClaimCodeForUser(string code, string username)
{
var result = false;
@@ -50,7 +50,7 @@ public class PhoneClaimCodeProviderService
return string.Empty;
}
public bool IsCodeGeneratedForUser(string username)
{
return _phoneClaimCodes.ContainsKey(username);

View File

@@ -16,13 +16,17 @@ public class AppConfiguration
this.SignalGroupId = Environment.GetEnvironmentVariable("SIGNAL_GROUP_ID") ?? "group.Wmk1UTVQTnh0Sjd6a0xiOGhnTnMzZlNkc2p2Q3c0SXJiQkU2eDlNU0hyTT0=";
this.WebUIBaseURL = Environment.GetEnvironmentVariable("WEB_BASE_URL") ?? "https://sotd.disi.dev/";
this.UseBotTag = bool.Parse(Environment.GetEnvironmentVariable("USE_BOT_TAG") ?? "true");
this.AverageDaysBetweenRequests = int.Parse(Environment.GetEnvironmentVariable("AVERAGE_DAYS_BETWEEN_REQUESTS") ?? "2");
this.DaysBetweenRequests = int.Parse(Environment.GetEnvironmentVariable("DAYS_BETWEEN_REQUESTS") ?? "2");
var managersGroupName = Environment.GetEnvironmentVariable("LDAP_ADMINGROUP") ?? "admins";
var userGroupName = Environment.GetEnvironmentVariable("LDAP_USERGROUP") ?? "everybody";
this.LDAPConfig = new ConfigurationAD() {
var bindValue = Environment.GetEnvironmentVariable("LDAP_BIND");
this.SpotifyClientId = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID") ?? "0c59b625470b4ad1b70743e0254d17fd";
this.SpotifyClientSecret = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_SECRET") ?? "04daaebd42fc47909c5cbd1f5cf23555";
this.LDAPConfig = new ConfigurationAD()
{
Username = Environment.GetEnvironmentVariable("LDAP_BIND") ?? "cn=admin,dc=disi,dc=dev",
Password = Environment.GetEnvironmentVariable("LDAP_PASS") ?? "adminPass2022!",
Port = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LDAP_BIND")) ? int.Parse(Environment.GetEnvironmentVariable("LDAP_BIND")) : 389,
Port = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LDAP_BIND")) ? int.Parse(bindValue ?? "389") : 389,
LDAPserver = Environment.GetEnvironmentVariable("LDAP_URL") ?? "192.168.1.108",
LDAPQueryBase = Environment.GetEnvironmentVariable("LDAP_BASE") ?? "dc=disi,dc=dev",
LDAPUserQueryBase = Environment.GetEnvironmentVariable("LDAP_BASE") ?? "ou=people,dc=disi,dc=dev",
@@ -81,17 +85,28 @@ public class AppConfiguration
get; private set;
}
public string SpotifyClientId
{
get; private set;
}
public string SpotifyClientSecret
{
get; private set;
}
public bool UseBotTag
{
get; private set;
}
public int AverageDaysBetweenRequests
public int DaysBetweenRequests
{
get; private set;
}
public ConfigurationAD LDAPConfig {
public ConfigurationAD LDAPConfig
{
get; private set;
}
}

View File

@@ -10,7 +10,7 @@ public class AuthController : Controller
public async Task<IActionResult> Login(string username, string password)
{
var ldapService = HttpContext.RequestServices.GetService<LdapAuthenticationService>();
if (ldapService.Authenticate(username, password))
if (ldapService != null && ldapService.Authenticate(username, password))
{
var claims = new[] { new Claim(ClaimTypes.Name, username) };
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

View File

@@ -0,0 +1,145 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace song_of_the_day.DataMigrations
{
[DbContext(typeof(DataContext))]
[Migration("20250524160218_additional data for song submissions")]
partial class additionaldataforsongsubmissions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Song", b =>
{
b.Property<int>("SongId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SongId"));
b.Property<string>("Artist")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.HasKey("SongId");
b.ToTable("Songs");
});
modelBuilder.Entity("SongSuggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<int?>("SongId")
.HasColumnType("integer");
b.Property<bool>("Submitted")
.HasColumnType("boolean");
b.Property<int?>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SongId");
b.HasIndex("UserId");
b.ToTable("SongSuggestions");
});
modelBuilder.Entity("SuggestionHelper", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("SuggestionHelpers");
});
modelBuilder.Entity("User", b =>
{
b.Property<int>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UserId"));
b.Property<bool>("AssociationInProgress")
.HasColumnType("boolean");
b.Property<bool>("IsIntroduced")
.HasColumnType("boolean");
b.Property<string>("LdapUserName")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NickName")
.HasColumnType("text");
b.Property<string>("SignalMemberId")
.HasColumnType("text");
b.HasKey("UserId");
b.ToTable("Users");
});
modelBuilder.Entity("SongSuggestion", b =>
{
b.HasOne("Song", "Song")
.WithMany()
.HasForeignKey("SongId");
b.HasOne("User", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("Song");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace song_of_the_day.DataMigrations
{
/// <inheritdoc />
public partial class additionaldataforsongsubmissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Submitted",
table: "SongSuggestions",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Submitted",
table: "SongSuggestions");
}
}
}

View File

@@ -0,0 +1,164 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace song_of_the_day.DataMigrations
{
[DbContext(typeof(DataContext))]
[Migration("20250524164159_keep track of users oicked for submission")]
partial class keeptrackofusersoickedforsubmission
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Song", b =>
{
b.Property<int>("SongId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SongId"));
b.Property<string>("Artist")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.HasKey("SongId");
b.ToTable("Songs");
});
modelBuilder.Entity("SongSuggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HasUsedSuggestion")
.HasColumnType("boolean");
b.Property<int?>("SongId")
.HasColumnType("integer");
b.Property<int>("SuggestionHelperId")
.HasColumnType("integer");
b.Property<bool>("UserHasSubmitted")
.HasColumnType("boolean");
b.Property<int?>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SongId");
b.HasIndex("SuggestionHelperId");
b.HasIndex("UserId");
b.ToTable("SongSuggestions");
});
modelBuilder.Entity("SuggestionHelper", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("SuggestionHelpers");
});
modelBuilder.Entity("User", b =>
{
b.Property<int>("UserId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("UserId"));
b.Property<bool>("AssociationInProgress")
.HasColumnType("boolean");
b.Property<bool>("IsIntroduced")
.HasColumnType("boolean");
b.Property<string>("LdapUserName")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("NickName")
.HasColumnType("text");
b.Property<string>("SignalMemberId")
.HasColumnType("text");
b.Property<bool>("WasChosenForSuggestionThisRound")
.HasColumnType("boolean");
b.HasKey("UserId");
b.ToTable("Users");
});
modelBuilder.Entity("SongSuggestion", b =>
{
b.HasOne("Song", "Song")
.WithMany()
.HasForeignKey("SongId");
b.HasOne("SuggestionHelper", "SuggestionHelper")
.WithMany()
.HasForeignKey("SuggestionHelperId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("User", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("Song");
b.Navigation("SuggestionHelper");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace song_of_the_day.DataMigrations
{
/// <inheritdoc />
public partial class keeptrackofusersoickedforsubmission : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Submitted",
table: "SongSuggestions",
newName: "UserHasSubmitted");
migrationBuilder.AddColumn<bool>(
name: "WasChosenForSuggestionThisRound",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HasUsedSuggestion",
table: "SongSuggestions",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SuggestionHelperId",
table: "SongSuggestions",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_SongSuggestions_SuggestionHelperId",
table: "SongSuggestions",
column: "SuggestionHelperId");
migrationBuilder.AddForeignKey(
name: "FK_SongSuggestions_SuggestionHelpers_SuggestionHelperId",
table: "SongSuggestions",
column: "SuggestionHelperId",
principalTable: "SuggestionHelpers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SongSuggestions_SuggestionHelpers_SuggestionHelperId",
table: "SongSuggestions");
migrationBuilder.DropIndex(
name: "IX_SongSuggestions_SuggestionHelperId",
table: "SongSuggestions");
migrationBuilder.DropColumn(
name: "WasChosenForSuggestionThisRound",
table: "Users");
migrationBuilder.DropColumn(
name: "HasUsedSuggestion",
table: "SongSuggestions");
migrationBuilder.DropColumn(
name: "SuggestionHelperId",
table: "SongSuggestions");
migrationBuilder.RenameColumn(
name: "UserHasSubmitted",
table: "SongSuggestions",
newName: "Submitted");
}
}
}

View File

@@ -54,9 +54,18 @@ namespace song_of_the_day.DataMigrations
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HasUsedSuggestion")
.HasColumnType("boolean");
b.Property<int?>("SongId")
.HasColumnType("integer");
b.Property<int>("SuggestionHelperId")
.HasColumnType("integer");
b.Property<bool>("UserHasSubmitted")
.HasColumnType("boolean");
b.Property<int?>("UserId")
.HasColumnType("integer");
@@ -64,6 +73,8 @@ namespace song_of_the_day.DataMigrations
b.HasIndex("SongId");
b.HasIndex("SuggestionHelperId");
b.HasIndex("UserId");
b.ToTable("SongSuggestions");
@@ -114,6 +125,9 @@ namespace song_of_the_day.DataMigrations
b.Property<string>("SignalMemberId")
.HasColumnType("text");
b.Property<bool>("WasChosenForSuggestionThisRound")
.HasColumnType("boolean");
b.HasKey("UserId");
b.ToTable("Users");
@@ -125,12 +139,20 @@ namespace song_of_the_day.DataMigrations
.WithMany()
.HasForeignKey("SongId");
b.HasOne("SuggestionHelper", "SuggestionHelper")
.WithMany()
.HasForeignKey("SuggestionHelperId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("User", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("Song");
b.Navigation("SuggestionHelper");
b.Navigation("User");
});
#pragma warning restore 612, 618

View File

@@ -6,4 +6,6 @@ public class Song
public string? Name { get; set; }
public string? Artist { get; set; }
public string? Url { get; set; }
public SongProvider? Provider { get; set; }
public string? SpotifyId { get; set; }
}

View File

@@ -0,0 +1,11 @@
public enum SongProvider
{
Spotify,
YouTube,
YoutubeMusic,
SoundCloud,
Bandcamp,
PlainHttp,
NavidromeSharedLink,
Other
}

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
public class SongSuggestion
@@ -7,4 +8,7 @@ public class SongSuggestion
public User? User { get; set; }
public Song? Song { get; set; }
public DateTime Date { get; set; }
public bool UserHasSubmitted { get; set; }
public required SuggestionHelper SuggestionHelper { get; set; }
public bool HasUsedSuggestion { get; set; }
}

View File

@@ -9,4 +9,5 @@ public class User
public bool IsIntroduced { get; set; }
public bool AssociationInProgress { get; set; }
public string? LdapUserName { get; set; }
public bool WasChosenForSuggestionThisRound { get; set; }
}

View File

@@ -0,0 +1,13 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "member", Target = "~P:sotd.Pages.UnclaimedPhoneNumbersModel.userId")]
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "member", Target = "~P:sotd.Pages.UserModel.userId")]
[assembly: SuppressMessage("Compiler", "CS8981:The type name only contains lower-cased ascii characters. Such names may become reserved for the language.", Justification = "<Pending>", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.additionaldataforsongsubmissions")]
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.additionaldataforsongsubmissions")]
[assembly: SuppressMessage("Compiler", "CS8981:The type name only contains lower-cased ascii characters. Such names may become reserved for the language.", Justification = "<Pending>", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.keeptrackofusersoickedforsubmission")]
[assembly: SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "<Pending>", Scope = "type", Target = "~T:song_of_the_day.DataMigrations.keeptrackofusersoickedforsubmission")]

View File

@@ -107,9 +107,10 @@ public class LdapIntegration
var userList = new List<LdapUser>();
foreach(SearchResultEntry result in response.Entries)
foreach (SearchResultEntry result in response.Entries)
{
userList.Add(new LdapUser() {
userList.Add(new LdapUser()
{
UserId = result.Attributes["uid"][0].ToString(),
FirstName = result.Attributes["givenName"][0].ToString(),
LastName = result.Attributes["sn"][0].ToString(),

View File

@@ -7,8 +7,13 @@ public class SignalIntegration
{
public static SignalIntegration? Instance;
private readonly ILogger logger;
public SignalIntegration(string uri, int port, string phoneNumber)
{
using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
this.logger = factory.CreateLogger("SignalIntegration");
var http = new HttpClient()
{
BaseAddress = new Uri(uri + ":" + port)
@@ -24,17 +29,19 @@ public class SignalIntegration
public async Task ListGroupsAsync()
{
logger.LogDebug("Listing all groups for phone number: {PhoneNumber}", this.phoneNumber);
try
{
ICollection<song_of_the_day.GroupEntry> groupEntries = await apiClient.GroupsAllAsync(this.phoneNumber);
foreach (var group in groupEntries)
{
Console.WriteLine($"{group.Name} {group.Id}");
logger.LogDebug($" {group.Name} {group.Id}");
}
}
catch (Exception ex)
{
Console.WriteLine("Exception (ListGroupsAsync): " + ex.Message);
logger.LogError("Exception (ListGroupsAsync): " + ex.Message);
}
}
@@ -52,7 +59,7 @@ public class SignalIntegration
}
catch (Exception ex)
{
Console.WriteLine("Exception (SendMessageToGroupAsync): " + ex.Message);
logger.LogError("Exception (SendMessageToGroupAsync): " + ex.Message);
}
}
@@ -70,7 +77,7 @@ public class SignalIntegration
}
catch (Exception ex)
{
Console.WriteLine("Exception (SendMessageToUserAsync): " + ex.Message);
logger.LogError("Exception (SendMessageToUserAsync): " + ex.Message);
}
}
@@ -97,7 +104,7 @@ public class SignalIntegration
}
catch (Exception ex)
{
Console.WriteLine("Exception (GetMemberListAsync): " + ex.Message);
logger.LogError("Exception (GetMemberListAsync): " + ex.Message);
}
return new List<string>();
}
@@ -117,7 +124,7 @@ public class SignalIntegration
}
catch (Exception ex)
{
Console.WriteLine("Exception (GetContactAsync): " + ex.Message);
logger.LogError("Exception (GetContactAsync): " + ex.Message);
return new ListContactsResponse();
}
}

View File

@@ -0,0 +1,2 @@
@inherits Microsoft.AspNetCore.Components.Forms.InputText
<input @attributes="@AdditionalAttributes" class="@CssClass" @bind="@CurrentValueAsString" @bind:event="oninput" />

View File

@@ -43,6 +43,12 @@
<a class="nav-link text-dark" asp-area="" asp-page="/UnclaimedPhoneNumbers">Unclaimed Phone Numbers</a>
</li>
}
@if (this.User.Identity.IsAuthenticated && DoesUserHaveClaimedPhoneNumber())
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/SubmitSongs">Submit Songs</a>
</li>
}
</ul>
</div>
</div>

View File

@@ -0,0 +1,7 @@
@model Song
<label asp-for="Name">Name:</label>
<input asp-for="Name" />
<label asp-for="Artist">Artist:</label>
<input asp-for="Artist" />
<label asp-for="SpotifyId">Spotify ID:</label>
<input asp-for="SpotifyId" />

View File

@@ -0,0 +1,25 @@
@page
@model SubmitSongsModel
@{
ViewData["Title"] = "Submit Songs";
}
<div class="text-left">
<form method="post">
<label asp-for="SubmitUrl" >Song Url:</label>
<input asp-for="SubmitUrl" oninput="Update(this)" />
</form>
<form method="post">
<div id="songdata">
<partial name="_SongPartial" model="@Model.SongData" />
</div>
<input type="submit" title="Submit" value="Submit" disabled="CanSubmit" />
</form>
</div>
<script>
function Update(t) {
var url = '?handler=Update&&SubmitUrl=' + $(t).val();
$('#songdata').load(url)
}
</script>

View File

@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.VisualBasic;
namespace sotd.Pages;
public class SubmitSongsModel : PageModel
{
private readonly ILogger<UserModel> _logger;
private SongResolver songResolver;
private string _submitUrl;
public SubmitSongsModel(ILogger<UserModel> logger, SongResolver songResolver)
{
_logger = logger;
this.songResolver = songResolver;
}
[BindProperty]
public bool IsValidUrl { get; set; } = true;
[BindProperty]
public string SubmitUrl {
get {
return _submitUrl;
}
set {
_submitUrl = value.ToString();
Uri? newValue = default;
try {
newValue = new Uri(_submitUrl);
}
catch (UriFormatException)
{
IsValidUrl = false;
return;
}
IsValidUrl = true;
if(this.songResolver.CanValidate(newValue))
{
this.SongData = this.songResolver.ResolveSongAsync(newValue).Result;
}
}
}
[BindProperty]
public bool CanSubmit { get {
return !string.IsNullOrEmpty(SongData?.Artist) && ! string.IsNullOrEmpty(SongData?.Name);
} }
[BindProperty]
public Song SongData { get; set; }
public void OnPost()
{
// Todo implement save submission
var x = SongData.Name;
}
public IActionResult OnGetUpdate()
{
var songUrl = Request.Query["SubmitUrl"];
this.SubmitUrl = songUrl.ToString();
return Partial("_SongPartial", SongData);;
}
}

View File

@@ -18,7 +18,10 @@ LdapIntegration.Instance = new LdapIntegration(AppConfiguration.Instance.LDAPCon
var builder = WebApplication.CreateBuilder(args);
Console.WriteLine("Setting up user check timer");
using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = factory.CreateLogger("SongResolver");
logger.LogTrace("Setting up user check timer");
var userCheckTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false);
userCheckTimer.OnOccurence += async (s, ea) =>
{
@@ -27,14 +30,13 @@ userCheckTimer.OnOccurence += async (s, ea) =>
var needsSaving = false;
foreach (var memberId in memberList)
{
Console.WriteLine("found member: " + memberId);
var foundUser = dci.Users.Where(u => u.SignalMemberId == memberId).SingleOrDefault();
var foundUser = dci.Users?.Where(u => u.SignalMemberId == memberId).SingleOrDefault();
if (foundUser == null)
{
var newUserContact = await SignalIntegration.Instance.GetContactAsync(memberId);
Console.WriteLine("New user:");
Console.WriteLine($" Name: {newUserContact.Name}");
Console.WriteLine($" MemberId: {memberId}");
logger.LogDebug("New user:");
logger.LogDebug($" Name: {newUserContact.Name}");
logger.LogDebug($" MemberId: {memberId}");
User newUser = new User()
{
Name = newUserContact.Name,
@@ -43,8 +45,9 @@ userCheckTimer.OnOccurence += async (s, ea) =>
IsIntroduced = false,
LdapUserName = string.Empty,
AssociationInProgress = false,
WasChosenForSuggestionThisRound = false,
};
dci.Users.Add(newUser);
dci.Users?.Add(newUser);
needsSaving = true;
}
}
@@ -55,14 +58,19 @@ userCheckTimer.OnOccurence += async (s, ea) =>
}
await dci.DisposeAsync();
};
userCheckTimer.Start();
Console.WriteLine("Setting up user intro timer");
logger.LogTrace("Setting up user intro timer");
var userIntroTimer = new CronTimer("*/1 * * * *", "Europe/Vienna", includingSeconds: false);
userIntroTimer.OnOccurence += async (s, ea) =>
{
var dci = DataContext.Instance;
var introUsers = dci.Users.Where(u => !u.IsIntroduced);
var introUsers = dci.Users?.Where(u => !u.IsIntroduced);
if (introUsers == null)
{
await dci.DisposeAsync();
return;
}
bool needsSaving = false;
foreach (var user in introUsers)
{
@@ -77,54 +85,97 @@ userIntroTimer.OnOccurence += async (s, ea) =>
}
await dci.DisposeAsync();
};
userIntroTimer.Start();
Console.WriteLine("Setting up pick of the day timer");
logger.LogTrace("Setting up pick of the day timer");
var pickOfTheDayTimer = new CronTimer("0 8 * * *", "Europe/Vienna", includingSeconds: false);
pickOfTheDayTimer.OnOccurence += async (s, ea) =>
{
var rand = new Random();
var num = rand.NextInt64();
var mod = num % AppConfiguration.Instance.AverageDaysBetweenRequests;
var dci = DataContext.Instance;
if (mod > 0)
var lastSong = dci.SongSuggestions?.OrderBy(s => s.Id).LastOrDefault();
if (lastSong != null && lastSong.Date > DateTime.Today.Subtract(TimeSpan.FromDays(AppConfiguration.Instance.DaysBetweenRequests)))
{
Console.WriteLine("Skipping pick of the day today!");
return;
logger.LogWarning("Skipping pick of the day today!");
await dci.DisposeAsync();
return;
}
var dci = DataContext.Instance;
var luckyUser = await dci.Users.ElementAtAsync((new Random()).Next(await dci.Users.CountAsync()));
if (dci.Users == null || dci.SuggestionHelpers == null || dci.SongSuggestions == null)
{
logger.LogError("Unable to properly initialize DB context!");
await dci.DisposeAsync();
return;
}
var potentialUsers = dci.Users.Where(u => !u.WasChosenForSuggestionThisRound);
if (!potentialUsers.Any())
{
logger.LogTrace("Resetting suggestion count on users before resuming");
await dci.Users.ForEachAsync(u => u.WasChosenForSuggestionThisRound = false);
await dci.SaveChangesAsync();
potentialUsers = dci.Users.Where(u => !u.WasChosenForSuggestionThisRound);
}
logger.LogDebug("Today's pool of pickable users is: " + string.Join(", ", potentialUsers.Select(u => u.Name)));
var luckyUser = potentialUsers.ElementAt((new Random()).Next(potentialUsers.Count()));
if (luckyUser == null)
{
logger.LogError("Unable to determine today's lucky user!");
await dci.DisposeAsync();
return;
}
var userName = string.IsNullOrEmpty(luckyUser.NickName) ? luckyUser.Name : luckyUser.NickName;
SignalIntegration.Instance.SendMessageToGroupAsync($"Today's chosen person to share a song is: **{userName}**");
SignalIntegration.Instance.SendMessageToUserAsync($"Congratulations, you have been chosen to share a song today!", luckyUser.SignalMemberId);
var suggestion = await dci.SuggestionHelpers.ElementAtAsync((new Random()).Next(await dci.SuggestionHelpers.CountAsync()));
SignalIntegration.Instance.SendMessageToUserAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*", luckyUser.SignalMemberId);
SignalIntegration.Instance.SendMessageToUserAsync($"For now please just share your suggestion with the group - in the future I might ask you to share directly with me or via the website to help me keep track of past suggestions!", luckyUser.SignalMemberId);
var newSongSuggestion = new SongSuggestion()
{
User = luckyUser,
SuggestionHelper = suggestion,
UserHasSubmitted = false,
HasUsedSuggestion = false,
Date = DateTime.Today.ToUniversalTime()
};
if (luckyUser.SignalMemberId is string signalId)
{
await dci.SongSuggestions.AddAsync(newSongSuggestion);
await dci.SaveChangesAsync();
await SignalIntegration.Instance.SendMessageToGroupAsync($"Today's chosen person to share a song is: **{userName}**");
await SignalIntegration.Instance.SendMessageToGroupAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*");
await SignalIntegration.Instance.SendMessageToUserAsync($"Congratulations, you have been chosen to share a song today!", signalId);
await SignalIntegration.Instance.SendMessageToUserAsync($"Today's (optional) suggestion helper to help you pick a song is:\n\n**{suggestion.Title}**\n\n*{suggestion.Description}*", signalId);
await SignalIntegration.Instance.SendMessageToUserAsync($"For now please just share your suggestion with the group - in the future I might ask you to share directly with me or via the website to help me keep track of past suggestions!", luckyUser.SignalMemberId);
}
await dci.DisposeAsync();
};
pickOfTheDayTimer.Start();
var startUserAssociationProcess = (User userToAssociate) =>
var startUserAssociationProcess = async (User userToAssociate) =>
{
SignalIntegration.Instance.SendMessageToUserAsync($"Hi, I see you are not associated with any website user yet.", userToAssociate.SignalMemberId);
SignalIntegration.Instance.SendMessageToUserAsync($"If you haven't yet, please navigate to https://users.disi.dev to create a new account.", userToAssociate.SignalMemberId);
SignalIntegration.Instance.SendMessageToUserAsync($"Once you have done so, go to https://sotd.disi.dev, login, navigate to \"Unclaimed Phone Numbers\" and click on the \"Claim\" button to start the claim process.", userToAssociate.SignalMemberId);
SignalIntegration.Instance.SendMessageToUserAsync($"With a future update you will be required to submit songs via your user account - at that point you will be skipped during the selection process if you have not yet claimed your phone number!", userToAssociate.SignalMemberId);
if (userToAssociate.SignalMemberId is string signalId)
{
await SignalIntegration.Instance.SendMessageToUserAsync($"Hi, I see you are not associated with any website user yet.", signalId);
await SignalIntegration.Instance.SendMessageToUserAsync($"If you haven't yet, please navigate to https://users.disi.dev to create a new account.", signalId);
await SignalIntegration.Instance.SendMessageToUserAsync($"Once you have done so, go to https://sotd.disi.dev, login, navigate to \"Unclaimed Phone Numbers\" and click on the \"Claim\" button to start the claim process.", signalId);
await SignalIntegration.Instance.SendMessageToUserAsync($"With a future update you will be required to submit songs via your user account - at that point you will be skipped during the selection process if you have not yet claimed your phone number!", signalId);
}
};
Console.WriteLine("Setting up LdapAssociation timer");
logger.LogTrace("Setting up LdapAssociation timer");
var ldapAssociationTimer = new CronTimer("*/10 * * * *", "Europe/Vienna", includingSeconds: false);
ldapAssociationTimer.OnOccurence += async (s, ea) =>
{
var dci = DataContext.Instance;
if (dci.Users == null)
{
logger.LogError("Unable to properly initialize DB context!");
await dci.DisposeAsync();
return;
}
var nonAssociatedUsers = dci.Users.Where(u => string.IsNullOrEmpty(u.LdapUserName) && !u.AssociationInProgress);
var needsSaving = false;
foreach (var user in nonAssociatedUsers)
{
user.AssociationInProgress = true;
startUserAssociationProcess(user);
await startUserAssociationProcess(user);
user.IsIntroduced = true;
needsSaving = true;
}
@@ -135,13 +186,6 @@ ldapAssociationTimer.OnOccurence += async (s, ea) =>
}
await dci.DisposeAsync();
};
ldapAssociationTimer.Start();
var searchResults = LdapIntegration.Instance.SearchInAD(
AppConfiguration.Instance.LDAPConfig.LDAPQueryBase,
$"(memberOf={AppConfiguration.Instance.LDAPConfig.CrewGroup})",
SearchScope.Subtree
);
// Add services to the container.
builder.Services.AddRazorPages();
@@ -154,9 +198,20 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
builder.Services.AddSingleton<LdapAuthenticationService>();
builder.Services.AddSingleton<PhoneClaimCodeProviderService>();
builder.Services.AddSingleton<SongResolver>();
var app = builder.Build();
// only start interaction timers in production builds
// for local/development testing we want those disabled
if (!app.Environment.IsDevelopment())
{
userCheckTimer.Start();
userIntroTimer.Start();
pickOfTheDayTimer.Start();
ldapAssociationTimer.Start();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{

View File

@@ -0,0 +1,8 @@
public interface ISongValidator
{
Task<Song> ValidateAsync(Uri songUri);
bool CanValidateUri(Uri songUri);
Task<bool> CanExtractSongMetadataAsync(Uri songUri);
}

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.Logging;
public class SongResolver
{
private readonly IEnumerable<ISongValidator> _songValidators;
private readonly ILogger logger;
public SongResolver()
{
using ILoggerFactory factory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Information));
this.logger = factory.CreateLogger("SongResolver");
this._songValidators = new List<ISongValidator>();
foreach (Type mytype in System.Reflection.Assembly.GetExecutingAssembly().GetTypes()
.Where(mytype => mytype.GetInterfaces().Contains(typeof(ISongValidator)) && !(mytype.Name.EndsWith("Base")))) {
if (Activator.CreateInstance(mytype) is ISongValidator validator)
{
logger.LogDebug("Registering song validator: {ValidatorType}", mytype.Name);
this._songValidators = this._songValidators.Append(validator);
}
}
}
public async Task<Song> ResolveSongAsync(Uri songUri)
{
foreach (var validator in _songValidators)
{
if (validator.CanValidateUri(songUri))
{
if (!await validator.CanExtractSongMetadataAsync(songUri))
{
this.logger.LogWarning("Cannot extract metadata for song URI: {SongUri}", songUri);
return new Song {
Artist = "Unknown Artist",
Name = "Unknown Title",
Url = songUri.ToString(),
Provider = SongProvider.PlainHttp,
};
}
return await validator.ValidateAsync(songUri);
}
}
return new Song {
Artist = "Unknown Artist",
Name = "Unknown Title",
Url = songUri.ToString(),
Provider = SongProvider.PlainHttp,
};
}
public bool CanValidate(Uri songUri)
{
foreach (var validator in _songValidators)
{
if (validator.CanValidateUri(songUri))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,16 @@
using System.Text.RegularExpressions;
public abstract class SongValidatorBase : ISongValidator
{
public abstract Task<Song> ValidateAsync(Uri songUri);
public abstract Task<bool> CanExtractSongMetadataAsync(Uri songUri);
public abstract bool CanValidateUri(Uri songUri);
protected string LookupSpotifyId(string songName, string songArtist)
{
// TODO: Implement Spotify ID lookup logic
return songName + " by " + songArtist;
}
}

View File

@@ -0,0 +1,40 @@
using System.Text.RegularExpressions;
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
public class SpotifyValidator : UriBasedSongValidatorBase
{
public override string UriValidatorRegex => @"^(https?://)?open.spotify.com/track/([a-zA-Z0-9_-]{22})(\?si=[a-zA-Z0-9_-]+)?$";
private SpotifyApiClient spotifyApiClient;
public SpotifyValidator()
{
spotifyApiClient = new SpotifyApiClient();
}
public override async Task<bool> CanExtractSongMetadataAsync(Uri songUri)
{
return this.CanValidateUri(songUri);
}
public override async Task<Song> ValidateAsync(Uri songUri)
{
var regexp = new Regex(UriValidatorRegex, RegexOptions.IgnoreCase);
var trackIdMatch = regexp.Match(songUri.ToString()).Groups[2].Value;
var track = await spotifyApiClient.GetTrackByIdAsync(trackIdMatch);
var song = new Song
{
Name = track.Name,
Artist = track.Artists.FirstOrDefault()?.Name ?? "Unknown Artist",
Url = songUri.ToString(),
Provider = SongProvider.Spotify,
SpotifyId = trackIdMatch
};
return song;
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.RegularExpressions;
public abstract class UriBasedSongValidatorBase : SongValidatorBase
{
public abstract string UriValidatorRegex { get; }
public override bool CanValidateUri(Uri songUri)
{
var regexp = new Regex(UriValidatorRegex, RegexOptions.IgnoreCase);
return regexp.Match(songUri.ToString()).Success;
}
}

View File

@@ -0,0 +1,43 @@
using AngleSharp;
using AngleSharp.Dom;
public class YoutubeMusicValidator : UriBasedSongValidatorBase
{
public override string UriValidatorRegex => @"^(https?://)?(music\.youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})";
public override async Task<bool> CanExtractSongMetadataAsync(Uri songUri)
{
return this.CanValidateUri(songUri);
}
public override async Task<Song> ValidateAsync(Uri songUri)
{
var title = string.Empty;
var artist = string.Empty;
using(HttpClient httpClient = new HttpClient())
{
var response = await httpClient.GetAsync(songUri);
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync())))
{
// document.getElementsByTagName("ytmusic-player-queue-item")[0].getElementsByClassName("song-title")[0].innerHTML
title = document.QuerySelector(".ytmusic-player-queue-item")?.QuerySelector(".song-title")?.InnerHtml;
// document.getElementsByTagName("ytmusic-player-queue-item")[0].getElementsByClassName("byline")[0].innerHTML
artist = document.QuerySelector(".ytmusic-player-queue-item")?.QuerySelector(".byline")?.InnerHtml;
}
}
var song = new Song
{
Name = title,
Artist = artist,
Url = songUri.ToString(),
Provider = SongProvider.YouTube,
SpotifyId = this.LookupSpotifyId(title, artist)
};
return song;
}
}

View File

@@ -0,0 +1,55 @@
using AngleSharp;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
public class YoutubeValidator : UriBasedSongValidatorBase
{
public override string UriValidatorRegex => @"^(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})";
public override async Task<bool> CanExtractSongMetadataAsync(Uri songUri)
{
using(HttpClient httpClient = new HttpClient())
{
var response = await httpClient.GetAsync(songUri);
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync())))
{
var documentContents = (document.ChildNodes[1] as HtmlElement).InnerHtml;
var titleElement = document.QuerySelectorAll(".yt-video-attribute-view-model__title")[0];
var artistParentElement = document.QuerySelectorAll(".yt-video-attribute-view-model__secondary-subtitle")[0];
return titleElement != null && artistParentElement != null && artistParentElement.Children.Length > 0;
}
}
}
public override async Task<Song> ValidateAsync(Uri songUri)
{
var title = string.Empty;
var artist = string.Empty;
using(HttpClient httpClient = new HttpClient())
{
var response = await httpClient.GetAsync(songUri);
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
using(var document = await context.OpenAsync(async req => req.Content(await response.Content.ReadAsStringAsync())))
{
title = document.QuerySelectorAll(".yt-video-attribute-view-model__title")[0]?.InnerHtml;
artist = document.QuerySelectorAll(".yt-video-attribute-view-model__secondary-subtitle")[0]?.Children[0]?.InnerHtml;
}
}
var song = new Song
{
Name = title,
Artist = artist,
Url = songUri.ToString(),
Provider = SongProvider.YouTube,
SpotifyId = this.LookupSpotifyId(title, artist)
};
return song;
}
}

View File

@@ -0,0 +1,44 @@
using SpotifyAPI.Web;
public class SpotifyApiClient
{
private SpotifyClient _spotifyClient;
public SpotifyApiClient()
{
var config = SpotifyClientConfig.CreateDefault()
.WithAuthenticator(new ClientCredentialsAuthenticator(
AppConfiguration.Instance.SpotifyClientId,
AppConfiguration.Instance.SpotifyClientSecret));
_spotifyClient = new SpotifyClient(config);
}
public async Task<List<FullTrack>> GetTrackCandidatesAsync(string trackName, string artistName)
{
try
{
var searchResponse = await _spotifyClient.Search.Item(new SearchRequest(SearchRequest.Types.Track, $"{trackName} {artistName}")
{
Limit = 5
});
return searchResponse.Tracks.Items ?? new List<FullTrack>();
}
catch (APIException ex)
{
throw new Exception($"Error fetching tracks by query: \"{trackName} {artistName}\": {ex.Message}", ex);
}
}
public async Task<FullTrack> GetTrackByIdAsync(string trackId)
{
try
{
return await _spotifyClient.Tracks.Get(trackId);
}
catch (APIException ex)
{
throw new Exception($"Error fetching track by ID: {trackId}: {ex.Message}", ex);
}
}
}

View File

@@ -1 +1 @@
0.2.1
0.3.3

View File

@@ -6,16 +6,18 @@
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NSwag.ApiDescription.Client" Version="13.0.5" />
<PackageReference Include="Scalar.AspNetCore" Version="2.1.*" />
<PackageReference Include="SpotifyAPI.Web" Version="7.2.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="*" />
</ItemGroup>
<ItemGroup>