Compare commits

..

1 Commits
master ... net

Author SHA1 Message Date
f26c378b06 initial net work 2022-08-22 20:16:48 +01:00
242 changed files with 4394 additions and 8167 deletions

View File

@ -10,39 +10,37 @@
*.userosscache
*.sln.docstates
**/appsettings.Development.json
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
**/[Dd]ebug/
**/[Dd]ebugPublic/
**/[Rr]elease/
**/[Rr]eleases/
**/x64/
**/x86/
**/bld/
**/[Bb]in/
**/[Oo]bj/
**/[Ll]og/
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
**/.vs/
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
**/[Tt]est[Rr]esult*/
**/[Bb]uild[Ll]og.*
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
**/[Dd]ebugPS/
**/[Rr]eleasePS/
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
@ -146,7 +144,7 @@ DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
**/publish/
publish/
# Publish Web Output
*.[Pp]ublish.xml
@ -202,7 +200,7 @@ ClientBin/
*.jfm
*.pfx
*.publishsettings
**/node_modules/
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components

View File

@ -1,93 +0,0 @@
name: ci
on: [push]
jobs:
build:
runs-on: ubuntu-latest
name: Build & Unit Test
strategy:
fail-fast: false
matrix:
dotnet-version: [ '8.0.x' ]
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.sheep-ghoul.ts.net
- name: Setup .NET Core SDK ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v3.0.3
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Install Dependencies
run: dotnet restore Selector.Core.sln
- name: Build
run: dotnet build --configuration Debug --no-restore Selector.Core.sln
- name: Test
run: dotnet test --no-restore --verbosity normal Selector.Core.sln
build-Docker:
runs-on: ubuntu-latest
name: Build Containers
needs: [build, build-Js] # for ignoring bad builds
if: gitea.event_name == 'push' && (gitea.ref == 'refs/heads/master' || startsWith(gitea.ref, 'refs/tags/'))
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.sheep-ghoul.ts.net
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: gitea.sheep-ghoul.ts.net
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build CLI Container
uses: docker/build-push-action@v6
with:
push: true
tags: gitea.sheep-ghoul.ts.net/sarsoo/selector-cli:latest
file: Dockerfile.CLI
context: .
- name: Build Web Container
uses: docker/build-push-action@v6
with:
push: true
tags: gitea.sheep-ghoul.ts.net/sarsoo/selector-web:latest
file: Dockerfile.Web
context: .
build-Js:
runs-on: ubuntu-latest
name: Build Frontend
strategy:
fail-fast: false
matrix:
node: [22]
steps:
- uses: actions/checkout@v4
with:
github-server-url: https://gitea.sheep-ghoul.ts.net
- name: Install Node ${{ matrix.node }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- name: Install Node Packages
working-directory: ./Selector.Web
run: npm ci
- name: Compile Front-end
working-directory: ./Selector.Web
run: npm run build --if-present

View File

@ -6,113 +6,66 @@ jobs:
build:
runs-on: ubuntu-latest
name: Build & Unit Test
strategy:
fail-fast: false
matrix:
dotnet-version: [ '8.0.x' ]
dotnet-version: [ '6.0.x' ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Setup .NET Core SDK ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v3.0.3
uses: actions/setup-dotnet@v1.7.2
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Install Dependencies
run: dotnet restore Selector.Core.sln
run: dotnet restore
- name: Build
run: dotnet build --configuration Debug --no-restore Selector.Core.sln
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal Selector.Core.sln
build-MAUI:
runs-on: windows-latest
name: Build MAUI
needs: [build] # for ignoring bad builds
strategy:
fail-fast: false
matrix:
dotnet-version: [ '8.0.x' ]
steps:
- uses: actions/checkout@v4
- name: Setup .NET Core SDK ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v3.0.3
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Build
run: dotnet build --configuration Debug Selector.MAUI\Selector.MAUI.csproj
run: dotnet test --no-restore --verbosity normal
build-Docker:
runs-on: ubuntu-latest
name: Build Containers
needs: [build, build-Js] # for ignoring bad builds
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/'))
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build CLI Container
uses: docker/build-push-action@v6
uses: docker/build-push-action@v2
with:
push: true
tags: |
sarsoo/selector-cli:latest
sarsoo/selector-cli:${{ github.ref_name }}
tags: sarsoo/selector-cli:latest
file: Dockerfile.CLI
- name: Build Web Container
uses: docker/build-push-action@v6
uses: docker/build-push-action@v2
with:
push: true
tags: |
sarsoo/selector-web:latest
sarsoo/selector-web:${{ github.ref_name }}
tags: sarsoo/selector-web:latest
file: Dockerfile.Web
deploy:
runs-on: ubuntu-latest
name: Deploy
needs: [build-Docker] # for ignoring bad builds
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/'))
environment:
name: prod
url: https://selector.sarsoo.xyz
steps:
- uses: actions/checkout@v4
- name: Tailscale
uses: tailscale/github-action@v2
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
version: 1.68.1
- name: Deploy
run: ssh -o StrictHostKeyChecking=no ${{ secrets.TS_SSH }} -t "cd selector/ && docker compose up -d --pull always"
build-Js:
runs-on: ubuntu-latest
name: Build Frontend
strategy:
fail-fast: false
matrix:
node: [22]
node: [16]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Install Node ${{ matrix.node }}
uses: actions/setup-node@v2

View File

@ -1,69 +0,0 @@
pipeline {
agent any
// environment {
// DOTNET_CLI_HOME = "/tmp/DOTNET_CLI_HOME"
// }
stages {
stage('Build C#') {
// agent {
// docker {
// image 'mcr.microsoft.com/dotnet/sdk:7.0'
// }
// }
steps {
// dotnetRestore project: "Selector.Core.sln", packages: './packages'
// sh 'dotnet build --packages ./packages Selector.Core.sln'
dotnetRestore project: "Selector.Core.sln"
dotnetBuild project: 'Selector.Core.sln'
}
}
stage('Build Javascript') {
// agent {
// docker {
// image 'node:16'
// reuseNode true
// }
// }
steps {
dir ('Selector.Web') {
sh "npm ci"
sh "npm run build --if-present"
}
}
}
stage('Test') {
// agent {
// docker {
// image 'mcr.microsoft.com/dotnet/sdk:7.0'
// reuseNode true
// }
// }
steps {
dotnetTest project: "Selector.Core.sln"
}
}
stage('Deploy') {
when { branch 'master' }
steps {
script {
docker.withRegistry('https://registry.sarsoo.xyz', 'git-registry-creds') {
docker.build("sarsoo/selector-cli:latest",
"-f Dockerfile.CLI .").push()
docker.build("sarsoo/selector-web:latest",
"-f Dockerfile.Web .").push()
}
}
}
}
}
post {
always {
cleanWs()
}
}
}

View File

@ -1,9 +1,8 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS base
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS base
COPY *.sln .
COPY Selector/*.csproj ./Selector/
COPY Selector.Cache/*.csproj ./Selector.Cache/
COPY Selector.Data/*.csproj ./Selector.Data/
COPY Selector.Event/*.csproj ./Selector.Event/
COPY Selector.Model/*.csproj ./Selector.Model/
COPY Selector.CLI/*.csproj ./Selector.CLI/
@ -13,14 +12,11 @@ RUN dotnet restore ./Selector.CLI/Selector.CLI.csproj
COPY . ./
FROM base as publish
RUN dotnet publish Selector.CLI/Selector.CLI.csproj -c Release -o /app
RUN dotnet publish Selector.CLI/Selector.CLI.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0
FROM mcr.microsoft.com/dotnet/aspnet:6.0
WORKDIR /app
COPY --from=publish /app ./
ENV DOTNET_EnableDiagnostics=0
USER app
ENTRYPOINT ["dotnet", "Selector.CLI.dll"]

View File

@ -1,4 +1,4 @@
FROM node:22 as frontend
FROM node as frontend
COPY ./Selector.Web/package.json /Selector.Web/
COPY ./Selector.Web/package-lock.json /Selector.Web/
WORKDIR /Selector.Web
@ -6,16 +6,14 @@ RUN npm ci
COPY ./Selector.Web ./
RUN npm run build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS base
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS base
COPY *.sln .
COPY Selector/*.csproj ./Selector/
COPY Selector.Cache/*.csproj ./Selector.Cache/
COPY Selector.Data/*.csproj ./Selector.Data/
COPY Selector.Event/*.csproj ./Selector.Event/
COPY Selector.Model/*.csproj ./Selector.Model/
COPY Selector.Web/*.csproj ./Selector.Web/
COPY Selector.SignalR/*.csproj ./Selector.SignalR/
COPY Selector.Tests/*.csproj ./Selector.Tests/
RUN dotnet restore ./Selector.Web/Selector.Web.csproj
@ -23,14 +21,12 @@ COPY . ./
FROM base as publish
COPY --from=frontend /Selector.Web/wwwroot Selector.Web/wwwroot/
RUN dotnet publish Selector.Web/Selector.Web.csproj -c Release -o /app
COPY --from=frontend /Selector.Web/wwwroot Selector.Web/wwwroot/
RUN dotnet publish Selector.Web/Selector.Web.csproj -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0
FROM mcr.microsoft.com/dotnet/aspnet:6.0
EXPOSE 80
WORKDIR /app
COPY --from=publish /app ./
ENV DOTNET_EnableDiagnostics=0
USER app
ENTRYPOINT ["dotnet", "Selector.Web.dll"]

BIN
Icon.xcf

Binary file not shown.

View File

@ -2,13 +2,8 @@
![ci](https://github.com/sarsoo/Selector/actions/workflows/ci.yml/badge.svg)
Selector is a web app for monitoring what you're listening to on Spotify. The Player Watcher keeps an eye on what you're currently playing and fires off events when things change.
The live updating dashboard shows data that the Spotify API shows you for a song, but that isn't visible in the Spotify client itself including the key, BPM, time signature, popularity and audio features.
Selector is a suite for monitoring and reacting to live changes on a Spotify account. The player watcher keeps an eye on what you're listening to and fires off events when things change. The idea is that various pieces of information will be collated and presented in a now-playing-style dashboard.
The audio features are shown in the radar graph below, it's a vector of 0.0 - 1.0 values for 7 categories including Energy, Acoustic-ness, Acapella-ness, Speech-iness and Dance-iness. These numbers are calculated by Spotify's AI/ML department and can be used for describing a track. This feature is perfect for comparing tracks in an ML context, although Spotify's TOS says you can't do that 😭.
You can connect your Last.fm account in order to show the cumulative play counts for the artist, album and track.
[Read the blog post.](https://sarsoo.xyz/selector/)
Last.fm play counts will be collected, as will the Spotify audio features.
![Dashboard Example](docs/dashboard.png)

View File

@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Selector.Model;
using SpotifyAPI.Web;
using StackExchange.Redis;
namespace Selector.CLI
{
@ -11,8 +10,7 @@ namespace Selector.CLI
{
public RootOptions Config { get; set; }
public ILoggerFactory Logger { get; set; }
public ISpotifyClient Spotify { get; set; }
public ConnectionMultiplexer RedisMux { get; set; }
public ISpotifyClient Spotify{ get; set; }
public DbContextOptionsBuilder<ApplicationDbContext> DatabaseConfig { get; set; }
public LastfmClient LastFmClient { get; set; }

View File

@ -9,6 +9,7 @@ using Selector.Extensions;
using System;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Threading.Tasks;
namespace Selector.CLI
{
@ -51,11 +52,11 @@ namespace Selector.CLI
private static void SetupExceptionHandling(ILogger logger, IHostEnvironment env)
{
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
AppDomain.CurrentDomain.UnhandledException += (obj, e) =>
{
if(e.ExceptionObject is Exception ex)
{
logger.LogError(ex, "Unhandled exception thrown");
logger.LogError(ex as Exception, "Unhandled exception thrown");
if (env.IsDevelopment())
{
@ -99,7 +100,6 @@ namespace Selector.CLI
.ConfigureDb(config);
services.AddConsumerFactories();
services.AddCLIConsumerFactories();
if (config.RedisOptions.Enabled)
{
Console.WriteLine("> Adding caching consumers...");
@ -151,10 +151,11 @@ namespace Selector.CLI
.AddNLog(context.Configuration);
}
static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> buildServices, Action<HostBuilderContext, ILoggingBuilder> buildLogs)
static IHostBuilder CreateHostBuilder(string[] args, Action<HostBuilderContext, IServiceCollection> BuildServices, Action<HostBuilderContext, ILoggingBuilder> BuildLogs)
=> Host.CreateDefaultBuilder(args)
.UseWindowsService()
.UseSystemd()
.ConfigureServices((context, services) => buildServices(context, services))
.ConfigureLogging((context, builder) => buildLogs(context, builder));
.ConfigureServices((context, services) => BuildServices(context, services))
.ConfigureLogging((context, builder) => BuildLogs(context, builder));
}
}

View File

@ -33,7 +33,7 @@ namespace Selector.CLI
Console.WriteLine("Migrate database? (y/n) ");
var input = Console.ReadLine();
if (input?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) ?? false)
if (input.Trim().Equals("y", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Migrating database");
db.Database.Migrate();

View File

@ -2,6 +2,7 @@
using Microsoft.Extensions.Logging;
using Selector.CLI.Extensions;
using Selector.Model;
using Selector.Model.Extensions;
using System;
using System.CommandLine;
using System.CommandLine.Invocation;

View File

@ -1,96 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Selector.CLI.Extensions;
using Selector.Data;
using Selector.Model;
using System;
using System.IO;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.Linq;
using System.Collections.Generic;
using Selector.Cache;
namespace Selector.CLI
{
public class SpotifyHistoryCommand : Command
{
public SpotifyHistoryCommand(string name, string description = null) : base(name, description)
{
var connectionString = new Option<string>("--connection", "database to migrate");
connectionString.AddAlias("-c");
AddOption(connectionString);
var pathString = new Option<string>("--path", "path to find data");
pathString.AddAlias("-i");
AddOption(pathString);
var username = new Option<string>("--username", "user to pulls scrobbles for");
username.AddAlias("-u");
AddOption(username);
Handler = CommandHandler.Create((string connection, string path, string username) => Execute(connection, path, username));
}
public static int Execute(string connection, string path, string username)
{
var streams = new List<FileStream>();
try
{
var context = new CommandContext().WithLogger().WithDb(connection).WithSpotify().WithRedis();
var logger = context.Logger.CreateLogger("Scrobble");
using var db = new ApplicationDbContext(context.DatabaseConfig.Options, context.Logger.CreateLogger<ApplicationDbContext>());
var historyPersister = new HistoryPersister(db, new DataJsonContext(), new()
{
Username = username,
Apply50PercentRule = true
},
durationPuller: new(context.Logger.CreateLogger<DurationPuller>(),
context.Spotify.Tracks,
cache: context.RedisMux.GetDatabase()),
logger: context.Logger.CreateLogger<HistoryPersister>());
logger.LogInformation("Preparing to parse from {} for {}", path, username);
var directoryContents = Directory.EnumerateFiles(path);
var endSongs = directoryContents.Where(f => f.Contains("endsong_")).ToArray();
foreach(var file in endSongs)
{
streams.Add(File.OpenRead(file));
}
Console.WriteLine("Parse {0} historical data files? (y/n) ", endSongs.Length);
var input = Console.ReadLine();
if (input.Trim().Equals("y", StringComparison.OrdinalIgnoreCase))
{
logger.LogInformation("Parsing files");
historyPersister.Process(streams).Wait();
}
else
{
logger.LogInformation("Exiting");
}
}
catch (Exception ex)
{
Console.WriteLine(ex);
return 1;
}
finally
{
foreach(var stream in streams)
{
stream.Dispose();
}
}
return 0;
}
}
}

View File

@ -1,33 +0,0 @@
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Selector.Model;
namespace Selector.CLI.Consumer
{
public interface IMappingPersisterFactory
{
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null);
}
public class MappingPersisterFactory : IMappingPersisterFactory
{
private readonly ILoggerFactory LoggerFactory;
private readonly IServiceScopeFactory ScopeFactory;
public MappingPersisterFactory(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory = null, LastFmCredentials creds = null)
{
LoggerFactory = loggerFactory;
ScopeFactory = scopeFactory;
}
public Task<IPlayerConsumer> Get(IPlayerWatcher watcher = null)
{
return Task.FromResult<IPlayerConsumer>(new MappingPersister(
watcher,
ScopeFactory,
LoggerFactory.CreateLogger<MappingPersister>()
));
}
}
}

View File

@ -1,148 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.Model;
using SpotifyAPI.Web;
namespace Selector.CLI.Consumer
{
/// <summary>
/// Save name -> Spotify URI mappings as new objects come through the watcher without making extra queries of the Spotify API
/// </summary>
public class MappingPersister: IPlayerConsumer
{
protected readonly IPlayerWatcher Watcher;
protected readonly IServiceScopeFactory ScopeFactory;
protected readonly ILogger<MappingPersister> Logger;
public CancellationToken CancelToken { get; set; }
public MappingPersister(
IPlayerWatcher watcher,
IServiceScopeFactory scopeFactory,
ILogger<MappingPersister> logger = null,
CancellationToken token = default
)
{
Watcher = watcher;
ScopeFactory = scopeFactory;
Logger = logger ?? NullLogger<MappingPersister>.Instance;
CancelToken = token;
}
public void Callback(object sender, ListeningChangeEventArgs e)
{
if (e.Current is null) return;
Task.Run(async () => {
try
{
await AsyncCallback(e);
}
catch (DbUpdateException)
{
Logger.LogWarning("Failed to update database, likely a duplicate Spotify URI");
}
catch (Exception ex)
{
Logger.LogError(ex, "Error occured during callback");
}
}, CancelToken);
}
public async Task AsyncCallback(ListeningChangeEventArgs e)
{
using var serviceScope = ScopeFactory.CreateScope();
using var scope = Logger.BeginScope(new Dictionary<string, object>() { { "spotify_username", e.SpotifyUsername }, { "id", e.Id } });
if (e.Current.Item is FullTrack track)
{
var mappingRepo = serviceScope.ServiceProvider.GetRequiredService<IScrobbleMappingRepository>();
if(!mappingRepo.GetTracks().Select(t => t.SpotifyUri).Contains(track.Uri))
{
mappingRepo.Add(new TrackLastfmSpotifyMapping()
{
SpotifyUri = track.Uri,
LastfmTrackName = track.Name,
LastfmArtistName = track.Artists.FirstOrDefault()?.Name
});
}
if (!mappingRepo.GetAlbums().Select(t => t.SpotifyUri).Contains(track.Album.Uri))
{
mappingRepo.Add(new AlbumLastfmSpotifyMapping()
{
SpotifyUri = track.Album.Uri,
LastfmAlbumName = track.Album.Name,
LastfmArtistName = track.Album.Artists.FirstOrDefault()?.Name
});
}
var artistUris = mappingRepo.GetArtists().Select(t => t.SpotifyUri).ToArray();
foreach (var artist in track.Artists)
{
if (!artistUris.Contains(artist.Uri))
{
mappingRepo.Add(new ArtistLastfmSpotifyMapping()
{
SpotifyUri = artist.Uri,
LastfmArtistName = artist.Name
});
}
}
await mappingRepo.Save();
Logger.LogDebug("Adding Spotify <-> Last.fm mapping [{username}]", e.SpotifyUsername);
}
else if (e.Current.Item is FullEpisode episode)
{
Logger.LogDebug("Ignoring podcast episode [{episode}]", episode.DisplayString());
}
else if (e.Current.Item is null)
{
Logger.LogDebug("Skipping play count pulling for null item [{context}]", e.Current.DisplayString());
}
else
{
Logger.LogError("Unknown item pulled from API [{item}]", e.Current.Item);
}
}
public void Subscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast)
{
watcherCast.ItemChange += Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
public void Unsubscribe(IWatcher watch = null)
{
var watcher = watch ?? Watcher ?? throw new ArgumentNullException(nameof(watch));
if (watcher is IPlayerWatcher watcherCast)
{
watcherCast.ItemChange -= Callback;
}
else
{
throw new ArgumentException("Provided watcher is not a PlayerWatcher");
}
}
}
}

View File

@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Selector.Model;
using SpotifyAPI.Web;
using StackExchange.Redis;
using System.Linq;
namespace Selector.CLI.Extensions
@ -92,21 +91,5 @@ namespace Selector.CLI.Extensions
return context;
}
public static CommandContext WithRedis(this CommandContext context)
{
if (context.Config is null)
{
context.WithConfig();
}
var connectionString = context.Config.RedisOptions.ConnectionString;
var connMulti = ConnectionMultiplexer.Connect(connectionString);
context.RedisMux = connMulti;
return context;
}
}
}

View File

@ -2,12 +2,15 @@
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Selector.Cache.Extensions;
using Selector.CLI.Consumer;
using Selector.CLI.Jobs;
using Selector.Extensions;
using Selector.Model;
using Selector.Model.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Selector.CLI.Extensions
{
@ -114,12 +117,7 @@ namespace Selector.CLI.Extensions
options.UseNpgsql(config.DatabaseOptions.ConnectionString)
);
services.AddTransient<IScrobbleRepository, ScrobbleRepository>()
.AddTransient<ISpotifyListenRepository, SpotifyListenRepository>();
services.AddTransient<IListenRepository, MetaListenRepository>();
//services.AddTransient<IListenRepository, SpotifyListenRepository>();
services.AddTransient<IScrobbleRepository, ScrobbleRepository>();
services.AddTransient<IScrobbleMappingRepository, ScrobbleMappingRepository>();
services.AddHostedService<MigratorService>();
@ -144,13 +142,5 @@ namespace Selector.CLI.Extensions
return services;
}
public static IServiceCollection AddCLIConsumerFactories(this IServiceCollection services)
{
services.AddTransient<IMappingPersisterFactory, MappingPersisterFactory>();
services.AddTransient<MappingPersisterFactory>();
return services;
}
}
}

View File

@ -1,38 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SpotifyAPI.Web;
namespace Selector.CLI.Extensions
{
public static class SpotifyExtensions
{
public static async Task<(FullPlaylist, IEnumerable<PlaylistTrack<IPlayableItem>>)> GetPopulated(this ISpotifyClient client, string playlistId, ILogger logger = null)
{
try
{
var playlist = await client.Playlists.Get(playlistId);
var items = await client.Paginate(playlist.Tracks).ToListAsync();
return (playlist, items);
}
catch (APIUnauthorizedException e)
{
logger?.LogDebug("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
throw;
}
catch (APITooManyRequestsException e)
{
logger?.LogDebug("Too many requests error: [{message}]", e.Message);
throw;
}
catch (APIException e)
{
logger?.LogDebug("API error: [{message}]", e.Message);
throw;
}
}
}
}

View File

@ -100,7 +100,7 @@ namespace Selector.CLI
public enum Consumers
{
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter, MappingPersister
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter
}
public class RedisOptions

View File

@ -9,7 +9,6 @@ namespace Selector.CLI
var cmd = new HostRootCommand();
cmd.AddCommand(new ScrobbleCommand("scrobble", "Manipulate scrobbles"));
cmd.AddCommand(new MigrateCommand("migrate", "Migrate database"));
cmd.AddCommand(new SpotifyHistoryCommand("history", "Insert Spotify history"));
cmd.Invoke(args);
}

View File

@ -51,9 +51,8 @@ namespace Selector
{
logger.LogInformation("Mapping scrobble tracks");
var currentTracks = mappingRepo.GetTracks().AsEnumerable();
var currentTracks = mappingRepo.GetTracks();
var scrobbleTracks = scrobbleRepo.GetAll()
.AsEnumerable()
.GroupBy(x => (x.ArtistName, x.TrackName))
.Select(x => (x.Key, x.Count()))
.OrderByDescending(x => x.Item2)
@ -69,7 +68,7 @@ namespace Selector
var requests = tracksToPull.Select(a => new ScrobbleTrackMapping(
searchClient,
loggerFactory.CreateLogger<ScrobbleTrackMapping>(),
loggerFactory.CreateLogger<ScrobbleTrackMapping>() ?? NullLogger<ScrobbleTrackMapping>.Instance,
a.TrackName, a.ArtistName)
).ToArray();

View File

@ -112,7 +112,7 @@ namespace Selector
logger.LogDebug("Identifying difference sets");
var time = Stopwatch.StartNew();
(var toAdd, var toRemove) = ListenMatcher.IdentifyDiffs(currentScrobbles, nativeScrobbles);
(var toAdd, var toRemove) = ScrobbleMatcher.IdentifyDiffs(currentScrobbles, nativeScrobbles);
time.Stop();
logger.LogTrace("Finished diffing: {:n}ms", time.ElapsedMilliseconds);

View File

@ -2,43 +2,38 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<StartupObject>Selector.CLI.Program</StartupObject>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="NLog" Version="5.3.2" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.11" />
<PackageReference Include="Quartz" Version="3.11.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.11.0" />
<PackageReference Include="SpotifyAPI.Web" Version="7.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" />
<PackageReference Include="NLog" Version="5.0.1" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.0.1" />
<PackageReference Include="Quartz" Version="3.4.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.4.0" />
<PackageReference Include="SpotifyAPI.Web" Version="6.2.2" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.21308.1" />
<PackageReference Include="System.CommandLine.Hosting" Version="0.3.0-alpha.21216.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="8.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector\Selector.csproj" />
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />
<ProjectReference Include="..\Selector.Data\Selector.Data.csproj" />
<ProjectReference Include="..\Selector.Event\Selector.Event.csproj" />
</ItemGroup>
<ItemGroup>
<None Remove="Consumer\" />
<None Remove="Consumer\Factory\" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Development.json" Condition="Exists('appsettings.Development.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@ -54,8 +49,4 @@
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Consumer\" />
<Folder Include="Consumer\Factory\" />
</ItemGroup>
</Project>

View File

@ -13,7 +13,6 @@ using Selector.Model;
using Selector.Model.Extensions;
using Selector.Events;
using System.Collections.Concurrent;
using Selector.CLI.Consumer;
namespace Selector.CLI
{
@ -36,9 +35,6 @@ namespace Selector.CLI
private readonly IPublisherFactory PublisherFactory;
private readonly ICacheWriterFactory CacheWriterFactory;
private readonly IMappingPersisterFactory MappingPersisterFactory;
private ConcurrentDictionary<string, IWatcherCollection> Watchers { get; set; } = new();
public DbWatcherService(
@ -57,8 +53,6 @@ namespace Selector.CLI
IPublisherFactory publisherFactory = null,
ICacheWriterFactory cacheWriterFactory = null,
IMappingPersisterFactory mappingPersisterFactory = null,
IUserEventFirerFactory userEventFirerFactory = null
)
{
@ -77,8 +71,6 @@ namespace Selector.CLI
PublisherFactory = publisherFactory;
CacheWriterFactory = cacheWriterFactory;
MappingPersisterFactory = mappingPersisterFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
@ -138,8 +130,6 @@ namespace Selector.CLI
if (CacheWriterFactory is not null) consumers.Add(await CacheWriterFactory.Get());
if (PublisherFactory is not null) consumers.Add(await PublisherFactory.Get());
if (MappingPersisterFactory is not null && !Magic.Dummy) consumers.Add(await MappingPersisterFactory.Get());
if (UserEventFirerFactory is not null) consumers.Add(await UserEventFirerFactory.Get());
if (dbWatcher.User.LastFmConnected())

View File

@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Selector.Cache;
using Selector.CLI.Consumer;
namespace Selector.CLI
{
@ -137,10 +136,6 @@ namespace Selector.CLI
Logger.LogError("No Last.fm username provided, skipping play counter");
}
break;
case Consumers.MappingPersister:
consumers.Add(await ServiceProvider.GetService<MappingPersisterFactory>().Get());
break;
}
}
}

View File

@ -4,19 +4,13 @@
"ClientSecret": "",
"Equality": "uri",
"Watcher": {
"LocalEnabled": false,
"localenabled": false,
"Instances": [
// {
// "Name": "test watcher",
// "type": "playlist",
// "PlaylistUri": "spotify:playlist:4o5IArXmDeJByESaUJoEFS",
// "pollperiod": 2000
// },
{
"type": "player",
"lastfmusername": "sarsoo",
"pollperiod": 2000,
"consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter", "mappingpersister" ]
"consumers": [ "audiofeaturescache", "cachewriter", "publisher", "playcounter" ]
}
]
},

View File

@ -31,9 +31,6 @@
<!-- rules to map from logger name to target -->
<rules>
<logger name="Selector.*" minlevel="Debug" writeTo="logconsole" />
<logger name="Microsoft.*" minlevel="Warning" writeTo="logconsole" />
<!--<logger name="*" minlevel="Debug" writeTo="logfile" />-->
<logger name="Selector.*" minlevel="Debug" writeTo="logfile" />
<logger name="Microsoft.*" minlevel="Warning" writeTo="logfile" />
@ -41,5 +38,8 @@
<!--<logger name="*" minlevel="Trace" writeTo="tracefile" />-->
<logger name="Selector.*" minlevel="Debug" writeTo="tracefile" />
<logger name="Microsoft.*" minlevel="Warning" writeTo="tracefile" />
<logger name="Selector.*" minlevel="Debug" writeTo="logconsole" />
<logger name="Microsoft.*" minlevel="Warning" writeTo="logconsole" />
</rules>
</nlog>

View File

@ -24,25 +24,15 @@ namespace Selector.Cache
public async Task<IPlayerConsumer> Get(ISpotifyConfigFactory spotifyFactory, IPlayerWatcher watcher = null)
{
if (!Magic.Dummy)
{
var config = await spotifyFactory.GetConfig();
var client = new SpotifyClient(config);
var config = await spotifyFactory.GetConfig();
var client = new SpotifyClient(config);
return new CachingAudioFeatureInjector(
watcher,
Db,
client.Tracks,
LoggerFactory.CreateLogger<CachingAudioFeatureInjector>()
);
}
else
{
return new DummyAudioFeatureInjector(
watcher,
LoggerFactory.CreateLogger<DummyAudioFeatureInjector>()
);
}
return new CachingAudioFeatureInjector(
watcher,
Db,
client.Tracks,
LoggerFactory.CreateLogger<CachingAudioFeatureInjector>()
);
}
}
}

View File

@ -1,26 +0,0 @@
using System;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace Selector.Cache;
public static class CacheExtensions
{
public static async Task<int?> GetTrackDuration(this IDatabaseAsync cache, string trackId)
{
return (int?) await cache?.HashGetAsync(Key.Track(trackId), Key.Duration);
}
public static async Task SetTrackDuration(this IDatabaseAsync cache, string trackId, int duration, TimeSpan? expiry = null)
{
var trackCacheKey = Key.Track(trackId);
await cache?.HashSetAsync(trackCacheKey, Key.Duration, duration);
if(expiry is not null)
{
await cache?.KeyExpireAsync(trackCacheKey, expiry);
}
}
}

View File

@ -20,7 +20,6 @@ namespace Selector.Cache
public const string AudioFeatureName = "AUDIO_FEATURE";
public const string PlayCountName = "PLAY_COUNT";
public const string Duration = "DURATION";
public const string SpotifyName = "SPOTIFY";
public const string LastfmName = "LASTFM";
@ -35,9 +34,6 @@ namespace Selector.Cache
public static string CurrentlyPlaying(string user) => MajorNamespace(MinorNamespace(UserName, CurrentlyPlayingName), user);
public static readonly string AllCurrentlyPlaying = CurrentlyPlaying(All);
public static string Track(string trackId) => MajorNamespace(TrackName, trackId);
public static readonly string AllTracks = Track(All);
public static string AudioFeature(string trackId) => MajorNamespace(MinorNamespace(TrackName, AudioFeatureName), trackId);
public static readonly string AllAudioFeatures = AudioFeature(All);

View File

@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<EnableDefaultCompileItems>true</EnableDefaultCompileItems>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StackExchange.Redis" Version="2.8.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="SpotifyAPI.Web" Version="7.1.1" />
<PackageReference Include="StackExchange.Redis" Version="2.6.48" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="SpotifyAPI.Web" Version="6.2.2" />
</ItemGroup>
<ItemGroup>

View File

@ -27,7 +27,7 @@ namespace Selector.Cache
var track = await Cache?.StringGetAsync(Key.AudioFeature(trackId));
if (Cache is null || track == RedisValue.Null)
{
if(!string.IsNullOrWhiteSpace(refreshToken) && !Magic.Dummy)
if(!string.IsNullOrWhiteSpace(refreshToken))
{
var factory = await SpotifyFactory.GetFactory(refreshToken);
var spotifyClient = new SpotifyClient(await factory.GetConfig());

View File

@ -1,183 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using SpotifyAPI.Web;
using StackExchange.Redis;
namespace Selector.Cache
{
public class DurationPuller
{
private readonly IDatabaseAsync Cache;
private readonly ILogger<DurationPuller> Logger;
protected readonly ITracksClient SpotifyClient;
private int _retries = 0;
public DurationPuller(
ILogger<DurationPuller> logger,
ITracksClient spotifyClient,
IDatabaseAsync cache = null
)
{
Cache = cache;
Logger = logger;
SpotifyClient = spotifyClient;
}
public async Task<int?> Get(string uri)
{
if (string.IsNullOrWhiteSpace(uri)) throw new ArgumentNullException("No uri provided");
var trackId = uri.Split(":").Last();
var cachedVal = await Cache?.HashGetAsync(Key.Track(trackId), Key.Duration);
if (Cache is null || cachedVal == RedisValue.Null || cachedVal.IsNullOrEmpty)
{
try {
Logger.LogDebug("Missed cache, pulling");
var info = await SpotifyClient.Get(trackId);
await Cache?.SetTrackDuration(trackId, info.DurationMs, TimeSpan.FromDays(7));
_retries = 0;
return info.DurationMs;
}
catch (APIUnauthorizedException e)
{
Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
throw e;
}
catch (APITooManyRequestsException e)
{
if(_retries <= 3)
{
Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter, e.Message);
_retries++;
await Task.Delay(e.RetryAfter);
return await Get(uri);
}
else
{
Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
throw e;
}
}
catch (APIException e)
{
if (_retries <= 3)
{
Logger.LogWarning("API error, retrying: [{message}]", e.Message);
_retries++;
await Task.Delay(TimeSpan.FromSeconds(2));
return await Get(uri);
}
else
{
Logger.LogError("API error, done retrying: [{message}]", e.Message);
throw e;
}
}
}
else
{
return (int?) cachedVal;
}
}
public async Task<IDictionary<string, int>> Get(IEnumerable<string> uri)
{
if (!uri.Any()) throw new ArgumentNullException("No URIs provided");
var ret = new Dictionary<string, int>();
var toPullFromSpotify = new List<string>();
foreach (var input in uri.Select(x => x.Split(":").Last()))
{
var cachedVal = await Cache?.HashGetAsync(Key.Track(input), Key.Duration);
if (Cache is null || cachedVal == RedisValue.Null || cachedVal.IsNullOrEmpty)
{
toPullFromSpotify.Add(input);
}
else
{
ret[input] = (int) cachedVal;
}
}
var retries = new List<string>();
foreach(var chunk in toPullFromSpotify.Chunk(50))
{
await PullChunk(chunk, ret);
await Task.Delay(TimeSpan.FromMilliseconds(500));
}
return ret;
}
private async Task PullChunk(IList<string> toPull, IDictionary<string, int> ret)
{
try
{
var info = await SpotifyClient.GetSeveral(new(toPull));
foreach (var resp in info.Tracks)
{
await Cache?.SetTrackDuration(resp.Id, resp.DurationMs, TimeSpan.FromDays(7));
ret[resp.Id] = (int)resp.DurationMs;
}
_retries = 0;
}
catch (APIUnauthorizedException e)
{
Logger.LogError("Unauthorised error: [{message}] (should be refreshed and retried?)", e.Message);
throw e;
}
catch (APITooManyRequestsException e)
{
if (_retries <= 3)
{
Logger.LogWarning("Too many requests error, retrying ({}): [{message}]", e.RetryAfter, e.Message);
_retries++;
await Task.Delay(e.RetryAfter);
await PullChunk(toPull, ret);
}
else
{
Logger.LogError("Too many requests error, done retrying: [{message}]", e.Message);
throw e;
}
}
catch (APIException e)
{
if (_retries <= 3)
{
Logger.LogWarning("API error, retrying: [{message}]", e.Message);
_retries++;
await Task.Delay(TimeSpan.FromSeconds(5));
await PullChunk(toPull, ret);
}
else
{
Logger.LogError("API error, done retrying: [{message}]", e.Message);
throw e;
}
}
}
}
}

View File

@ -1,73 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31919.166
MinimumVisualStudioVersion = 15.0.26124.0
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector", "Selector\Selector.csproj", "{05B22ACE-2EA1-46AA-8483-A625B08A0D01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Tests", "Selector.Tests\Selector.Tests.csproj", "{2C33766F-FFEB-4A91-9509-7B543EDB6F93}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.CLI", "Selector.CLI\Selector.CLI.csproj", "{AB0C1359-2A4D-4013-BC5A-79C55203C15E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Model", "Selector.Model\Selector.Model.csproj", "{B609975D-7CA6-422E-8461-E837C9EDB104}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Web", "Selector.Web\Selector.Web.csproj", "{ABC6EEBB-4C0D-45BD-8DDC-0B0304EAF34F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Cache", "Selector.Cache\Selector.Cache.csproj", "{D8761D46-EF2B-4323-894F-E67C3EB0D0BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Event", "Selector.Event\Selector.Event.csproj", "{C2FF1673-CB1A-43B7-A814-07BB3CB3A0D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.Data", "Selector.Data\Selector.Data.csproj", "{CB62ACCB-94F1-4B78-A195-8B108B9E800D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Selector.SignalR", "Selector.SignalR\Selector.SignalR.csproj", "{089C9DE8-2B73-4341-BA17-572CD6BAD14D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{05B22ACE-2EA1-46AA-8483-A625B08A0D01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05B22ACE-2EA1-46AA-8483-A625B08A0D01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05B22ACE-2EA1-46AA-8483-A625B08A0D01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05B22ACE-2EA1-46AA-8483-A625B08A0D01}.Release|Any CPU.Build.0 = Release|Any CPU
{2C33766F-FFEB-4A91-9509-7B543EDB6F93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2C33766F-FFEB-4A91-9509-7B543EDB6F93}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2C33766F-FFEB-4A91-9509-7B543EDB6F93}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2C33766F-FFEB-4A91-9509-7B543EDB6F93}.Release|Any CPU.Build.0 = Release|Any CPU
{AB0C1359-2A4D-4013-BC5A-79C55203C15E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AB0C1359-2A4D-4013-BC5A-79C55203C15E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AB0C1359-2A4D-4013-BC5A-79C55203C15E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AB0C1359-2A4D-4013-BC5A-79C55203C15E}.Release|Any CPU.Build.0 = Release|Any CPU
{B609975D-7CA6-422E-8461-E837C9EDB104}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B609975D-7CA6-422E-8461-E837C9EDB104}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B609975D-7CA6-422E-8461-E837C9EDB104}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B609975D-7CA6-422E-8461-E837C9EDB104}.Release|Any CPU.Build.0 = Release|Any CPU
{ABC6EEBB-4C0D-45BD-8DDC-0B0304EAF34F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABC6EEBB-4C0D-45BD-8DDC-0B0304EAF34F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABC6EEBB-4C0D-45BD-8DDC-0B0304EAF34F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABC6EEBB-4C0D-45BD-8DDC-0B0304EAF34F}.Release|Any CPU.Build.0 = Release|Any CPU
{D8761D46-EF2B-4323-894F-E67C3EB0D0BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8761D46-EF2B-4323-894F-E67C3EB0D0BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8761D46-EF2B-4323-894F-E67C3EB0D0BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8761D46-EF2B-4323-894F-E67C3EB0D0BB}.Release|Any CPU.Build.0 = Release|Any CPU
{C2FF1673-CB1A-43B7-A814-07BB3CB3A0D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2FF1673-CB1A-43B7-A814-07BB3CB3A0D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2FF1673-CB1A-43B7-A814-07BB3CB3A0D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2FF1673-CB1A-43B7-A814-07BB3CB3A0D6}.Release|Any CPU.Build.0 = Release|Any CPU
{CB62ACCB-94F1-4B78-A195-8B108B9E800D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB62ACCB-94F1-4B78-A195-8B108B9E800D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB62ACCB-94F1-4B78-A195-8B108B9E800D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB62ACCB-94F1-4B78-A195-8B108B9E800D}.Release|Any CPU.Build.0 = Release|Any CPU
{089C9DE8-2B73-4341-BA17-572CD6BAD14D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{089C9DE8-2B73-4341-BA17-572CD6BAD14D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{089C9DE8-2B73-4341-BA17-572CD6BAD14D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{089C9DE8-2B73-4341-BA17-572CD6BAD14D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {24A812EA-34C1-43DE-AAD2-C02025730573}
EndGlobalSection
EndGlobal

View File

@ -1,14 +0,0 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Selector.Data;
[JsonSerializable(typeof(EndSong))]
[JsonSerializable(typeof(EndSong[]))]
public partial class DataJsonContext : JsonSerializerContext
{
}

View File

@ -1,27 +0,0 @@
namespace Selector.Data;
public record struct EndSong
{
public string conn_country { get; set; }
public string episode_name { get; set; }
public string episode_show_name { get; set; }
public bool? incognito_mode { get; set; }
public string ip_addr_decrypted { get; set; }
public string master_metadata_album_album_name { get; set; }
public string master_metadata_album_artist_name { get; set; }
public string master_metadata_track_name { get; set; }
public int ms_played { get; set; }
public bool? offline { get; set; }
public long? offline_timestamp { get; set; }
public string platform { get; set; }
public string reason_end { get; set; }
public string reason_start { get; set; }
public bool shuffle { get; set; }
public bool? skipped { get; set; }
public string spotify_episode_uri { get; set; }
public string spotify_track_uri { get; set; }
public string ts { get; set; }
public string user_agent_decrypted { get; set; }
public string username { get; set; }
}

View File

@ -1,210 +0,0 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Selector.Cache;
using Selector.Model;
using static SpotifyAPI.Web.PlaylistRemoveItemsRequest;
namespace Selector.Data;
public class HistoryPersisterConfig
{
public string Username { get; set; }
public bool InitialClear { get; set; } = true;
public bool Apply50PercentRule { get; set; } = false;
}
public class HistoryPersister
{
private HistoryPersisterConfig Config { get; set; }
private ApplicationDbContext Db { get; set; }
private DataJsonContext Json { get; set; }
private DurationPuller DurationPuller { get; set; }
private ILogger<HistoryPersister> Logger { get; set; }
private readonly Dictionary<string, int> Durations;
public HistoryPersister(
ApplicationDbContext db,
DataJsonContext json,
HistoryPersisterConfig config,
DurationPuller durationPuller = null,
ILogger<HistoryPersister> logger = null)
{
Config = config;
Db = db;
Json = json;
DurationPuller = durationPuller;
Logger = logger;
if (config.Apply50PercentRule && DurationPuller is null)
{
throw new ArgumentNullException(nameof(DurationPuller));
}
Durations = new();
}
public void Process(string input)
{
var parsed = JsonSerializer.Deserialize(input, Json.EndSongArray);
Process(parsed).Wait();
}
public async Task Process(Stream input)
{
var parsed = await JsonSerializer.DeserializeAsync(input, Json.EndSongArray);
await Process(parsed);
}
public async Task Process(IEnumerable<Stream> input)
{
var songs = Enumerable.Empty<EndSong>();
foreach(var singleInput in input)
{
var parsed = await JsonSerializer.DeserializeAsync(singleInput, Json.EndSongArray);
songs = songs.Concat(parsed);
Logger?.LogDebug("Parsed {:n0} items for {}", parsed.Length, Config.Username);
}
await Process(songs);
}
public async Task Process(IEnumerable<EndSong> input)
{
var user = Db.Users.Single(u => u.UserName == Config.Username);
if (Config.InitialClear)
{
var latestTime = input.OrderBy(x => x.ts).Last().ts;
var time = DateTime.Parse(latestTime).ToUniversalTime();
Db.SpotifyListen.RemoveRange(Db.SpotifyListen.Where(x => x.UserId == user.Id && x.Timestamp <= time));
}
var filtered = input.Where(x => x.ms_played > 30000
&& !string.IsNullOrWhiteSpace(x.master_metadata_track_name))
.DistinctBy(x => (x.offline_timestamp, x.ts, x.spotify_track_uri))
.ToArray();
Logger.LogInformation("{:n0} items after filtering", filtered.Length);
var processedCounter = 0;
foreach (var item in filtered.Chunk(1000))
{
IEnumerable<EndSong> toPopulate = item;
if (Config.Apply50PercentRule)
{
Logger.LogDebug("Validating tracks {:n0}/{:n0}", processedCounter + 1, filtered.Length);
toPopulate = Passes50PcRule(toPopulate);
}
Db.SpotifyListen.AddRange(toPopulate.Select(x => new SpotifyListen()
{
TrackName = x.master_metadata_track_name,
AlbumName = x.master_metadata_album_album_name,
ArtistName = x.master_metadata_album_artist_name,
Timestamp = DateTime.Parse(x.ts).ToUniversalTime(),
PlayedDuration = x.ms_played,
TrackUri = x.spotify_track_uri,
UserId = user.Id
}));
processedCounter += item.Length;
}
Logger?.LogInformation("Saving {:n0} historical items for {}", processedCounter, user.UserName);
await Db.SaveChangesAsync();
Logger?.LogInformation("Added {:n0} historical items for {}", processedCounter, user.UserName);
}
private const int FOUR_MINUTES = 4 * 60 * 1000;
public async Task<bool> Passes50PcRule(EndSong song)
{
if (string.IsNullOrWhiteSpace(song.spotify_track_uri)) return true;
int duration;
if (Durations.TryGetValue(song.spotify_track_uri, out duration))
{
}
else
{
var pulledDuration = await DurationPuller.Get(song.spotify_track_uri);
if (pulledDuration is int d)
{
duration = d;
Durations.Add(song.spotify_track_uri, duration);
}
else
{
Logger.LogDebug("No duration returned for {}/{}", song.master_metadata_track_name, song.master_metadata_album_artist_name);
return true; // if can't get duration, just pass
}
}
return CheckDuration(song, duration);
}
public IEnumerable<EndSong> Passes50PcRule(IEnumerable<EndSong> inputTracks)
{
var toPullOverWire = new List<EndSong>();
// quick return items from local cache
foreach(var track in inputTracks)
{
if (string.IsNullOrWhiteSpace(track.spotify_track_uri)) yield return track;
if (Durations.TryGetValue(track.spotify_track_uri, out var duration))
{
if (CheckDuration(track, duration))
{
yield return track;
}
}
else
{
toPullOverWire.Add(track);
}
}
var pulledDuration = DurationPuller.Get(toPullOverWire.Select(x => x.spotify_track_uri)).Result;
// apply results to cache
foreach((var uri, var dur) in pulledDuration)
{
Durations[uri] = dur;
}
// check return acceptable tracks from pulled
foreach(var track in toPullOverWire)
{
if(pulledDuration.TryGetValue(track.spotify_track_uri, out var duration))
{
if(CheckDuration(track, duration))
{
yield return track;
}
}
else
{
yield return track;
}
}
}
public bool CheckDuration(EndSong song, int duration) => song.ms_played >= duration / 2 || song.ms_played >= FOUR_MINUTES;
}

View File

@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Selector.Model\Selector.Model.csproj" />
<ProjectReference Include="..\Selector.Cache\Selector.Cache.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Selector.MAUI"
x:Class="Selector.MAUI.App">
<Application.Resources>
<ResourceDictionary>
<Color x:Key="PageBackgroundColor">#2b2b2b</Color>
<Color x:Key="PrimaryTextColor">White</Color>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{DynamicResource PrimaryTextColor}" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{DynamicResource PrimaryTextColor}" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="BackgroundColor" Value="#2b0b98" />
<Setter Property="Padding" Value="14,10" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -1,51 +0,0 @@
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Selector.SignalR;
namespace Selector.MAUI;
public partial class App : Application
{
private readonly NowHubClient nowClient;
private readonly ILogger<App> logger;
public App(NowHubClient nowClient, ILogger<App> logger)
{
InitializeComponent();
MainPage = new MainPage();
this.nowClient = nowClient;
this.logger = logger;
}
protected override Window CreateWindow(IActivationState activationState)
{
Window window = base.CreateWindow(activationState);
window.Resumed += async (s, e) =>
{
try
{
logger.LogInformation("Window resumed, reconnecting hubs");
if (nowClient.State == HubConnectionState.Disconnected)
{
await nowClient.StartAsync();
}
await nowClient.OnConnected();
logger.LogInformation("Hubs reconnected");
}
catch(Exception ex)
{
logger.LogError(ex, "Error while reconnecting hubs");
}
};
return window;
}
}

View File

@ -1,10 +0,0 @@
using System;
namespace Selector.MAUI
{
public static class Constants
{
public const string JwtPrefKey = "last_jwt_key";
public const string StartPagePrefKey = "start_page";
}
}

View File

@ -1,13 +0,0 @@
namespace Selector.MAUI.Data;
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
}

View File

@ -1,21 +0,0 @@
namespace Selector.MAUI.Data;
public class WeatherForecastService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
{
return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
}).ToArray());
}
}

View File

@ -1,21 +0,0 @@
using System;
using Selector.MAUI.Services;
using Selector.SignalR;
namespace Selector.MAUI.Extensions;
public static class ServiceExtensions
{
public static IServiceCollection AddHubs(this IServiceCollection services)
{
services.AddSingleton<NowHubClient>()
.AddSingleton<NowHubCache>();
services.AddSingleton<PastHubClient>();
services.AddSingleton<HubManager>();
return services;
}
}

View File

@ -1,12 +0,0 @@
<Router AppAssembly="@typeof(Main).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Selector.MAUI"
x:Class="Selector.MAUI.MainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app" ComponentType="{x:Type local:Main}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>

View File

@ -1,10 +0,0 @@
namespace Selector.MAUI;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}

View File

@ -1,43 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Selector.MAUI.Data;
using Selector.MAUI.Services;
using Selector.SignalR;
using Selector.MAUI.Extensions;
namespace Selector.MAUI;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
builder.Services.AddLogging(o =>
{
//o.AddConsole();
});
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
builder.Services.AddHttpClient()
.AddTransient<ISelectorNetClient, SelectorNetClient>();
builder.Services.AddSingleton<SessionManager>()
.AddTransient<StartPageManager>();
builder.Services.AddHubs();
return builder.Build();
}
}

View File

@ -1,9 +0,0 @@
using System;
namespace Selector.MAUI.Models;
public class LoginModel
{
public string Username { get; set; }
public string Password { get; set; }
}

View File

@ -1,47 +0,0 @@
@page "/fetchdata"
@using Selector.MAUI.Data
@inject WeatherForecastService ForecastService
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from a service.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[] forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
}
}

View File

@ -1,14 +0,0 @@
@page "/app"
@using Selector.SignalR
@inject HubManager hubManager
@inject ILogger<Index> logger
<h1 class="text-center">run that</h1>
@code {
protected async override Task OnInitializedAsync()
{
await hubManager.EnsureConnected();
}
}

View File

@ -1,34 +0,0 @@
@page "/"
@using Selector.MAUI.Services;
@inject ILogger<Login> logger;
@inject NavigationManager NavManager;
@inject SessionManager sessionManager;
@inject HubManager hubManager;
@inject StartPageManager startManager;
<img class="spinning centered-spinning" src="/appicon.png" />
@code {
protected async override Task OnInitializedAsync()
{
logger.LogInformation("Starting up");
await sessionManager.LoadUserFromDisk();
if (sessionManager.IsLoggedIn)
{
await hubManager.EnsureConnected();
logger.LogInformation("User logged in, navigating to main app");
startManager.NavigateToStartPage();
}
else
{
logger.LogInformation("User not logged in, navigating to login");
NavManager.NavigateTo("/login");
}
}
}

View File

@ -1,72 +0,0 @@
body {
}
.centered-spinning {
position: fixed;
top: 50%;
left: 50%;
margin-left: -50px;
margin-top: -50px;
}
.spinning {
width: 100px;
height: 100px;
background: #f00;
-webkit-animation-name: spin;
-webkit-animation-duration: 1000ms;
-webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: ease;
-moz-animation-name: spin;
-moz-animation-duration: 1000ms;
-moz-animation-iteration-count: infinite;
-moz-animation-timing-function: ease;
-ms-animation-name: spin;
-ms-animation-duration: 1000ms;
-ms-animation-iteration-count: infinite;
-ms-animation-timing-function: ease;
animation-name: spin;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: ease;
}
@-ms-keyframes spin {
from {
-ms-transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
}
}
@-moz-keyframes spin {
from {
-moz-transform: rotate(0deg);
}
to {
-moz-transform: rotate(360deg);
}
}
@-webkit-keyframes spin {
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,58 +0,0 @@
@page "/login"
@inject SessionManager session
@inject NavigationManager navigation
<div class="form-container">
<h1 class="text-center">Login</h1>
<p>@toast</p>
<EditForm Model="@loginModel" OnSubmit="@HandleSubmit">
<InputText id="username" type="text" placeholder="Username" @bind-Value="loginModel.Username" tabindex=1 class="input-boxes" />
<InputText type="password" placeholder="Password" @bind-Value="loginModel.Password" tabindex=2 class="input-boxes" />
<div class="row" style="margin-top: 15px">
<RadzenButton Text="Submit" ButtonType="ButtonType.Submit" IsBusy=@isLoading ButtonStyle="ButtonStyle.Info" />
</div>
</EditForm>
</div>
@code {
private LoginModel loginModel = new();
private string toast = string.Empty;
private bool isLoading = false;
[Inject]
private ILogger<Login> logger { get; set; }
private async Task HandleSubmit()
{
isLoading = true;
var authResult = await session.Authenticate(loginModel.Username, loginModel.Password);
isLoading = false;
switch (authResult)
{
case SelectorNetClient.TokenResponseStatus.Malformed:
toast = "Bad Request, Username or Password missing";
break;
case SelectorNetClient.TokenResponseStatus.UserSearchFailed:
toast = "User not found";
break;
case SelectorNetClient.TokenResponseStatus.BadCreds:
toast = "Login failed, try again";
break;
case SelectorNetClient.TokenResponseStatus.ExpiredCreds:
toast = "Credentials expired, try again";
break;
case SelectorNetClient.TokenResponseStatus.OK:
logger.LogInformation("Login succeeded, redirecting");
navigation.NavigateTo("/app");
break;
}
}
}

View File

@ -1,32 +0,0 @@

.form-container {
max-width: 300px;
margin: auto;
}
h1 {
margin-top: 10px;
margin-bottom: 10px;
}
p {
margin-top: 10px;
margin-bottom: 10px;
}
::deep .input-boxes {
display: block;
width: 100%;
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
button {
margin-left: auto;
margin-right: auto;
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
display: block;
}

View File

@ -1,150 +0,0 @@
@page "/now"
@using Selector.SignalR;
@using System.Linq;
@implements IDisposable
<h1 class="text-center">Now</h1>
<div class="app text-center">
<NowPlayingCard Track="@nowCache.LastPlaying?.Track" Episode="@nowCache.LastPlaying?.Episode" />
<PlayCountCard Track="@nowCache.LastPlaying?.Track" Count="@nowCache.LastPlayCount" Username="@nowCache.LastPlayCount?.Username" />
@if (nowCache.LastPlayCount?.AlbumCountData?.Count() > 3)
{
<div class="chart-card card">
<RadzenChart>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.ArtistCountData" CategoryProperty="TimeStamp" Title="Artist" ValueProperty="Value" Stroke="#598556" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.AlbumCountData" CategoryProperty="TimeStamp" Title="Album" ValueProperty="Value" Stroke="#a34c77" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.TrackCountData" CategoryProperty="TimeStamp" Title="Track" ValueProperty="Value" Stroke="#7a99c2" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenValueAxis>
<RadzenGridLines Visible="true" />
</RadzenValueAxis>
<RadzenCategoryAxis>
<RadzenGridLines Visible="true" />
</RadzenCategoryAxis>
<RadzenLegend Position="LegendPosition.Bottom" />
</RadzenChart>
</div>
}
@if (nowCache.LastPlayCount?.TrackCountData?.Count() > 3)
{
<div class="chart-card card">
<h2>Track History</h2>
<RadzenChart>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.TrackCountData" CategoryProperty="TimeStamp" Title="Track" ValueProperty="Value" Stroke="#7a99c2" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenValueAxis>
<RadzenGridLines Visible="true" />
</RadzenValueAxis>
<RadzenCategoryAxis>
<RadzenGridLines Visible="true" />
</RadzenCategoryAxis>
<RadzenLegend Visible="false" />
</RadzenChart>
</div>
}
@if (nowCache.LastPlayCount?.AlbumCountData?.Count() > 3)
{
<div class="chart-card card">
<h2>Album History</h2>
<RadzenChart>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.AlbumCountData" CategoryProperty="TimeStamp" Title="Album" ValueProperty="Value" Stroke="#a34c77" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenValueAxis>
<RadzenGridLines Visible="true" />
</RadzenValueAxis>
<RadzenCategoryAxis>
<RadzenGridLines Visible="true" />
</RadzenCategoryAxis>
<RadzenLegend Visible="false" />
</RadzenChart>
</div>
}
@if (nowCache.LastPlayCount?.ArtistCountData?.Count() > 3)
{
<div class="chart-card card">
<h2>Artist History</h2>
<RadzenChart>
<RadzenLineSeries Smooth="@smooth" Data="@nowCache.LastPlayCount.ArtistCountData" CategoryProperty="TimeStamp" Title="Artist" ValueProperty="Value" Stroke="#598556" StrokeWidth="@strokeWidth">
<RadzenSeriesDataLabels Visible="@showDataLabels" />
</RadzenLineSeries>
<RadzenValueAxis>
<RadzenGridLines Visible="true" />
</RadzenValueAxis>
<RadzenCategoryAxis>
<RadzenGridLines Visible="true" />
</RadzenCategoryAxis>
<RadzenLegend Visible="false" />
</RadzenChart>
</div>
}
</div>
<div class="v-space"></div>
@code {
[Inject]
private NowHubCache nowCache { get; set; }
private bool smooth = true;
private bool showDataLabels = false;
private double strokeWidth = 5;
protected override void OnInitialized()
{
nowCache.NewNowPlaying += OnNewPlaying;
nowCache.NewCard += OnNewCard;
nowCache.NewPlayCount += OnNewPlayCount;
nowCache.NewAudioFeature += OnNewAudioFeature;
}
private void OnNewPlaying(object sender, EventArgs args)
{
Update();
}
private void OnNewCard(object sender, EventArgs args)
{
Update();
}
private void OnNewPlayCount(object sender, EventArgs args)
{
Update();
}
private void OnNewAudioFeature(object sender, EventArgs args)
{
Update();
}
public void Update()
{
Application.Current.Dispatcher.Dispatch(() =>
{
StateHasChanged();
});
}
public void Dispose()
{
nowCache.NewNowPlaying -= OnNewPlaying;
nowCache.NewCard -= OnNewCard;
nowCache.NewPlayCount -= OnNewPlayCount;
nowCache.NewAudioFeature -= OnNewAudioFeature;
}
}

View File

@ -1,20 +0,0 @@
body {
}
.app {
display: block;
}
h1 {
padding-bottom: 20px;
}
@media (min-width: 641px) {
.app {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
width: 100%
}
}

View File

@ -1,21 +0,0 @@
@page "/past"
@using Selector.SignalR;
<h1 class="text-center">Past</h1>
<div class="app text-center">
</div>
<div class="v-space"></div>
@code {
protected override void OnInitialized()
{
}
}

View File

@ -1,20 +0,0 @@
body {
}
.app {
display: block;
}
h1 {
padding-bottom: 20px;
}
@media (min-width: 641px) {
.app {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
width: 100%
}
}

View File

@ -1,66 +0,0 @@
@page "/settings"
<div class="form-container">
<h1 class="text-center">Settings</h1>
<div class="row">
<RadzenCard>
<RadzenText TextStyle="TextStyle.Subtitle2" TagName="TagName.H3">Start Page</RadzenText>
<RadzenDropDown AllowClear="true" TValue="string" Class="w-100"
Data=@startManager.StartPages
@bind-Value="currentStartPage"
Change=@OnStartPageChange />
</RadzenCard>
</div>
<div class="row">
<RadzenButton Click=@(_ => OpenAbout()) Text="About" ButtonStyle="ButtonStyle.Info" />
</div>
<div class="row" style="margin-bottom: 30px">
<RadzenButton Click=@(_ => OpenPrivacy()) Text="Privacy Policy" ButtonStyle="ButtonStyle.Info" />
</div>
<div class="row">
<RadzenButton Click=@(_ => SignOut()) Text="Sign Out" ButtonStyle="ButtonStyle.Danger" />
</div>
<SignatureImage />
</div>
<div class="v-space"></div>
@code {
[Inject]
private SessionManager sessionManager { get; set; }
[Inject]
private NavigationManager navigationManager { get; set; }
[Inject]
private StartPageManager startManager { get; set; }
private string currentStartPage { get; set; }
protected override void OnInitialized()
{
currentStartPage = startManager.GetStartPage();
}
private void OnStartPageChange(object value)
{
startManager.SetStartPage((string) value);
}
private void SignOut()
{
sessionManager.SignOut();
navigationManager.NavigateTo("/");
}
private async void OpenAbout()
{
await Browser.Default.OpenAsync("https://sarsoo.xyz/selector");
}
private async void OpenPrivacy()
{
await Browser.Default.OpenAsync("https://selector.sarsoo.xyz/privacy");
}
}

View File

@ -1,20 +0,0 @@
body {
}
.form-container {
max-width: 300px;
margin: auto;
}
.row {
padding-top: 5px;
padding-bottom: 5px;
}
::deep h3 {
text-align: left;
}
h1 {
padding-bottom: 20px;
}

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -1,11 +0,0 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace Selector.MAUI;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}

View File

@ -1,16 +0,0 @@
using Android.App;
using Android.Runtime;
namespace Selector.MAUI;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@ -1,10 +0,0 @@
using Foundation;
namespace Selector.MAUI;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@ -1,15 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace Selector.MAUI;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@ -1,17 +0,0 @@
using System;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace Selector.MAUI;
class Program : MauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
static void Main(string[] args)
{
var app = new Program();
app.Run(args);
}
}

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="7" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="Selector.MAUI.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@ -1,9 +0,0 @@
<maui:MauiWinUIApplication
x:Class="Selector.MAUI.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:Selector.MAUI.WinUI">
</maui:MauiWinUIApplication>

View File

@ -1,25 +0,0 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Selector.MAUI.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="8F98654D-A15B-402E-8E42-0BF977E78581" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Selector.MAUI.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@ -1,10 +0,0 @@
using Foundation;
namespace Selector.MAUI;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@ -1,16 +0,0 @@
using ObjCRuntime;
using UIKit;
namespace Selector.MAUI;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}

View File

@ -1,8 +0,0 @@
{
"profiles": {
"Windows Machine": {
"commandName": "MsixPackage",
"nativeDebugging": false
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,17 +0,0 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with you package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,143 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('OSX'))">$(TargetFrameworks);net8.0-ios;net8.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net8.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net8.0-tizen</TargetFrameworks> -->
<OutputType>Exe</OutputType>
<RootNamespace>Selector.MAUI</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
<!-- Display name -->
<ApplicationTitle>Selector</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>xyz.sarsoo.selector-maui</ApplicationId>
<ApplicationIdGuid>D33C256B-9FD7-4EA2-A675-C859295E71B2</ApplicationIdGuid>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">14.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net8.0-maccatalyst'">
<RuntimeIdentifier>maccatalyst-arm64</RuntimeIdentifier>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-ios|AnyCPU'">
<CreatePackage>false</CreatePackage>
<CodesignProvision>Automatic</CodesignProvision>
<CodesignKey>iPhone Developer</CodesignKey>
<!-- <CodesignEntitlements>Platforms\iOS\Entitlements.Debug.plist</CodesignEntitlements> -->
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net8.0-ios|AnyCPU'">
<CreatePackage>false</CreatePackage>
<MtouchLink>None</MtouchLink>
</PropertyGroup>
<!-- <PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst' and '$(Configuration)' == 'Debug'">
<CodeSignEntitlements>Platforms/MacCatalyst/Entitlements.Debug.plist</CodeSignEntitlements>
</PropertyGroup> -->
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net8.0-maccatalyst|AnyCPU'">
<CreatePackage>false</CreatePackage>
<CodesignKey>Mac Developer</CodesignKey>
<PackageSigningKey>3rd Party Mac Developer Installer</PackageSigningKey>
<EnableCodeSigning>True</EnableCodeSigning>
<CodesignEntitlements>Platforms/MacCatalyst/Entitlements.Debug.plist</CodesignEntitlements>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net8.0-maccatalyst|AnyCPU'">
<CreatePackage>false</CreatePackage>
<CodesignKey>Mac Developer</CodesignKey>
<PackageSigningKey>3rd Party Mac Developer Installer</PackageSigningKey>
<EnableCodeSigning>True</EnableCodeSigning>
<MtouchLink>None</MtouchLink>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.png" Color="#2b2b2b" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.png" Color="#2b2b2b" />
<!-- Images -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="System.Net.Http.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<!-- <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.2" /> -->
<PackageReference Include="Microsoft.AspNetCore.Components.Forms" Version="8.0.7" />
<PackageReference Include="Radzen.Blazor" Version="4.34.4" />
</ItemGroup>
<ItemGroup>
<None Remove="Services\" />
<None Remove="Microsoft.Extensions.Http" />
<None Remove="System.Net.Http.Json" />
<None Remove="NLog.Extensions.Logging" />
<None Remove="NLog" />
<None Remove="Microsoft.Extensions.Logging.Console" />
<None Remove="Models\" />
<None Remove="Microsoft.AspNetCore.SignalR.Client" />
<None Remove="Microsoft.AspNetCore.Components.Forms" />
<None Remove="Radzen.Blazor" />
<None Remove="Extensions\" />
<None Remove="Resources\Images\" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services\" />
<Folder Include="Models\" />
<Folder Include="Extensions\" />
<Folder Include="Resources\Images\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Selector\Selector.csproj" />
<ProjectReference Include="..\Selector.SignalR\Selector.SignalR.csproj" />
</ItemGroup>
<ItemGroup>
<Content Remove="nlog.config" />
<Content Remove="wwwroot\appicon.png" />
</ItemGroup>
<ItemGroup>
<!-- <None Include="nlog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> -->
</ItemGroup>
<ItemGroup>
<BundleResource Include="wwwroot\andy.png">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</BundleResource>
<BundleResource Include="wwwroot\last_fm.png">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</BundleResource>
<BundleResource Include="wwwroot\live.gif">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</BundleResource>
<BundleResource Include="wwwroot\spotify_icon.png">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</BundleResource>
<BundleResource Include="wwwroot\appicon.png">
<Color>#2b2b2b</Color>
</BundleResource>
</ItemGroup>
</Project>

View File

@ -1,58 +0,0 @@
using System;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Selector.SignalR;
namespace Selector.MAUI.Services;
public class HubManager
{
private readonly NowHubClient nowClient;
private readonly NowHubCache nowCache;
private readonly PastHubClient pastClient;
private readonly ILogger<HubManager> logger;
public HubManager(NowHubClient nowClient, NowHubCache nowCache, PastHubClient pastClient, ILogger<HubManager> logger)
{
this.nowClient = nowClient;
this.nowCache = nowCache;
this.pastClient = pastClient;
this.logger = logger;
}
public async Task EnsureConnected()
{
var nowTask = Task.CompletedTask;
var pastTask = Task.CompletedTask;
if (nowClient.State == HubConnectionState.Disconnected)
{
logger.LogInformation("Starting now hub connection");
nowTask = nowClient.StartAsync().ContinueWith(async x =>
{
if (x.IsCompletedSuccessfully)
{
nowCache.BindClient();
await nowClient.OnConnected();
}
});
}
if (pastClient.State == HubConnectionState.Disconnected)
{
logger.LogInformation("Starting past hub connection");
pastTask = pastClient.StartAsync().ContinueWith(async x =>
{
if (x.IsCompletedSuccessfully)
{
await pastClient.OnConnected();
}
});
}
await Task.WhenAll(nowTask, pastTask);
}
}

View File

@ -1,121 +0,0 @@
using System;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Selector.SignalR;
namespace Selector.MAUI.Services;
public interface ISelectorNetClient
{
Task<SelectorNetClient.TokenResponse> GetToken(string username, string password);
Task<SelectorNetClient.TokenResponse> GetToken(string currentKey);
}
public class SelectorNetClient : ISelectorNetClient
{
private readonly HttpClient _client;
private readonly string _baseUrl;
private readonly NowHubClient _nowClient;
private readonly PastHubClient _pastClient;
public SelectorNetClient(HttpClient client, NowHubClient nowClient, PastHubClient pastClient)
{
_client = client;
_nowClient = nowClient;
_pastClient = pastClient;
//var baseOverride = Environment.GetEnvironmentVariable("SELECTOR_BASE_URL");
//if (!string.IsNullOrWhiteSpace(baseOverride))
//{
// _baseUrl = baseOverride;
//}
//else
//{
// _baseUrl = "https://selector.sarsoo.xyz";
//}
// _baseUrl = "http://localhost:5000";
_baseUrl = "https://selector.sarsoo.xyz";
}
public async Task<TokenResponse> GetToken(string username, string password)
{
ArgumentNullException.ThrowIfNullOrEmpty(username);
ArgumentNullException.ThrowIfNullOrEmpty(password);
var result = await _client.PostAsync(_baseUrl + "/api/auth/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "Username", username },
{ "Password", password }
}));
return FormTokenResponse(result);
}
public async Task<TokenResponse> GetToken(string currentKey)
{
ArgumentNullException.ThrowIfNullOrEmpty(currentKey);
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + "/api/auth/token");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentKey);
var result = await _client.SendAsync(request);
return FormTokenResponse(result);
}
private TokenResponse FormTokenResponse(HttpResponseMessage result)
{
var ret = new TokenResponse();
switch (result.StatusCode)
{
case HttpStatusCode.BadRequest:
ret.Status = TokenResponseStatus.Malformed;
break;
case HttpStatusCode.NotFound:
ret.Status = TokenResponseStatus.UserSearchFailed;
break;
case HttpStatusCode.Unauthorized:
ret.Status = TokenResponseStatus.BadCreds;
break;
case HttpStatusCode.Forbidden:
ret.Status = TokenResponseStatus.ExpiredCreds;
break;
case HttpStatusCode.OK:
ret.Status = TokenResponseStatus.OK;
ret.Token = result.Content.ReadFromJsonAsync<TokenNetworkResponse>().Result.Token;
_nowClient.Token = ret.Token;
_pastClient.Token = ret.Token;
break;
default:
break;
}
return ret;
}
public class TokenResponse
{
public string Token { get; set; }
public TokenResponseStatus Status { get; set; }
}
public class TokenNetworkResponse
{
public string Token { get; set; }
}
public enum TokenResponseStatus
{
Malformed, UserSearchFailed, BadCreds, ExpiredCreds, OK
}
private class TokenModel
{
public string Username { get; set; }
public string Password { get; set; }
}
}

View File

@ -1,99 +0,0 @@
using System;
using Microsoft.Extensions.Logging;
namespace Selector.MAUI.Services;
public class SessionManager
{
private string lastStoredKey;
private DateTime lastRefresh;
private readonly ISelectorNetClient _selectorNetClient;
private readonly ILogger<SessionManager> _logger;
public SessionManager(ISelectorNetClient selectorNetClient, ILogger<SessionManager> logger)
{
_selectorNetClient = selectorNetClient;
_logger = logger;
}
public bool IsLoggedIn => !string.IsNullOrWhiteSpace(lastStoredKey);
public async Task LoadUserFromDisk()
{
//var lastToken = await SecureStorage.Default.GetAsync(jwt_keychain_key);
var lastToken = Preferences.Default.Get(Constants.JwtPrefKey, string.Empty);
lastStoredKey = lastToken;
if(!string.IsNullOrWhiteSpace(lastToken))
{
await Authenticate();
}
}
public async Task<SelectorNetClient.TokenResponseStatus> Authenticate(string username, string password)
{
_logger.LogDebug("Making login token request");
var tokenResponse = await _selectorNetClient.GetToken(username, password);
return await HandleTokenResponse(tokenResponse);
}
public async Task<SelectorNetClient.TokenResponseStatus> Authenticate()
{
_logger.LogDebug("Making token request with current key");
var tokenResponse = await _selectorNetClient.GetToken(lastStoredKey);
return await HandleTokenResponse(tokenResponse);
}
private Task<SelectorNetClient.TokenResponseStatus> HandleTokenResponse(SelectorNetClient.TokenResponse tokenResponse)
{
switch (tokenResponse.Status)
{
case SelectorNetClient.TokenResponseStatus.OK:
_logger.LogInformation("Token response ok");
lastStoredKey = tokenResponse.Token;
lastRefresh = DateTime.Now;
//await SecureStorage.Default.SetAsync(jwt_keychain_key, lastStoredKey);
// I know, but I can't get secure storage to work
Preferences.Default.Set(Constants.JwtPrefKey, lastStoredKey);
break;
case SelectorNetClient.TokenResponseStatus.Malformed:
_logger.LogInformation("Token request failed, missing username or password");
break;
case SelectorNetClient.TokenResponseStatus.UserSearchFailed:
_logger.LogInformation("Token request failed, no user by that name");
break;
case SelectorNetClient.TokenResponseStatus.ExpiredCreds:
_logger.LogInformation("Token expired, log back in");
lastStoredKey = null;
lastRefresh = DateTime.Now;
Preferences.Default.Remove(Constants.JwtPrefKey);
break;
case SelectorNetClient.TokenResponseStatus.BadCreds:
_logger.LogInformation("Token request failed, bad password");
break;
default:
throw new NotImplementedException();
}
return Task.FromResult(tokenResponse.Status);
}
public void SignOut()
{
lastStoredKey = null;
Preferences.Default.Clear();
}
}

View File

@ -1,62 +0,0 @@
using System;
using Microsoft.AspNetCore.Components;
namespace Selector.MAUI.Services;
public class StartPageManager
{
private readonly NavigationManager navManager;
public string[] StartPages { get; } = new[]
{
Home, Now, Past
};
public const string Home = "Home";
public const string Now = "Now";
public const string Past = "Past";
public StartPageManager(NavigationManager navManager)
{
this.navManager = navManager;
}
public string GetStartPage()
{
var savedStartPage = Preferences.Default.Get(Constants.StartPagePrefKey, string.Empty);
if (!string.IsNullOrWhiteSpace(savedStartPage))
{
return savedStartPage;
}
else
{
return Home;
}
}
public void NavigateToStartPage()
{
var startPage = GetStartPage();
switch (startPage)
{
case Now:
navManager.NavigateTo("/now");
break;
case Past:
navManager.NavigateTo("/past");
break;
case Home:
default:
navManager.NavigateTo("/app");
break;
}
}
public void SetStartPage(string value)
{
Preferences.Default.Set(Constants.StartPagePrefKey, value);
}
}

View File

@ -1,15 +0,0 @@
@if (!string.IsNullOrWhiteSpace(Link))
{
<a href="@Link" target="_blank" class="lastfm-logo">
<img src="/last_fm.png">
</a>
}
else
{
<img src="/last_fm.png" class="lastfm-logo">
}
@code {
[Parameter]
public string Link { get; set; }
}

View File

@ -1,40 +0,0 @@
@using Selector.MAUI.Services;
@inherits LayoutComponentBase
@inject SessionManager session
<div class="page">
<main>
@*<div class="top-row px-4">
<a href="https://sarsoo.xyz/posts/selector/" class="dash-underline-lg link-dark" target="_blank">About</a>
</div>*@
<article class="content px-4">
@Body
</article>
</main>
@if (session.IsLoggedIn)
{
<div class="sidebar">
<NavMenu />
</div>
}
</div>
@*<footer class="footer text-muted">
<div class="container">
&copy; 2023 - Selector.MAUI - <a href="https://selector.sarsoo.xyz/privacy/">Privacy</a>
</div>
<div style="text-align: center">
<a href="https://sarsoo.xyz/about/" style="display: inline-block">
<img src="/andy.png"
alt="AP"
width="120px"
style="display: block;
margin-left: auto;
margin-right: auto;
padding: 8px">
</a>
</div>
</footer>*@

View File

@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
/* background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); */
background-color: #232323;
}
.top-row {
background-color: #232323;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row:not(.auth) {
display: none;
}
.top-row.auth {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@ -1,48 +0,0 @@

<div class="bottom-bar">
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<nav class="flex-column menu-items">
<div class="nav-item px-3">
<NavLink class="nav-link" href="app" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="now">
<span class="oi oi-media-play" aria-hidden="true"></span> Now
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="past">
<span class="oi oi-magnifying-glass" aria-hidden="true"></span> Past
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="settings">
<span class="oi oi-cog" aria-hidden="true"></span> Settings
</NavLink>
</div>
</nav>
</div>
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Selector</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
</div>
@code {
private bool collapseNavMenu = true;
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}

View File

@ -1,83 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: #232323;
}
.navbar-brand {
font-size: 1.1rem;
}
.menu-items {
background-color: #3c3c3c;
}
.oi {
width: 2rem;
font-size: 1.1rem;
vertical-align: text-top;
top: -2px;
}
.bottom-bar {
position: fixed;
bottom: 0px;
width: 100%;
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.25);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.menu-items {
background-color: rgb(0, 0, 0, 0);
}
.bottom-bar {
position: static;
bottom: 0px;
width: 100%;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
}

View File

@ -1,52 +0,0 @@
@using SpotifyAPI.Web;
@if (Track is not null) {
<div class="card now-playing-card">
<img src="@imageUrl" class="cover-art">
<h4>@Track.Name</h4>
<h6>
@Track.Album.Name
</h6>
<h6>
<span>
@string.Join(", ", Track.Artists.Select(x => x.Name))
</span>
</h6>
<div style="width: 100%">
<SpotifyLogo Link="@Track.ExternalUrls?.FirstOrDefault(x => x.Key == "Spotify").Value" />
<img src="/live.gif" style="height: 20px; float: right">
</div>
</div>
}
else if (Episode is not null) {
<div class="card now-playing-card">
<img src="@imageUrl" class="cover-art">
<h4>@Episode.Name</h4>
<h6>
@Episode.Show.Name
</h6>
<h6>
@Episode.Show.Publisher
</h6>
<div style="width: 100%">
<SpotifyLogo Link="@Episode.ExternalUrls?.FirstOrDefault(x => x.Key == "Spotify").Value" />
<img src="/live.gif" style="height: 20px; float: right">
</div>
</div>
}
else
{
<div class="card now-playing-card">
<h4>No Playback</h4>
</div>
}
@code {
[Parameter]
public FullTrack Track { get; set; }
[Parameter]
public FullEpisode Episode { get; set; }
private string imageUrl => Track?.Album?.Images?.FirstOrDefault()?.Url ?? Episode?.Show?.Images?.FirstOrDefault()?.Url ?? string.Empty;
}

View File

@ -1,74 +0,0 @@
@using SpotifyAPI.Web;
@if (Count is not null) {
<div class="card info-card">
@if (Count.Track is not null) {
<h5>
<a href="@trackLink" class="subtle-link" style="color: #7a99c2">
Track: @Count.Track
@if (trackPercent >= 0.01)
{
<small> (@(trackPercentDisplay)%)</small>
}
</a>
</h5>
}
@if (Count.Album is not null)
{
<h5>
<a href="@albumLink" class="subtle-link" style="color: #a34c77">
Album: @Count.Album
@if (albumPercent >= 0.01)
{
<small> (@(albumPercentDisplay)%)</small>
}
</a>
</h5>
}
@if (Count.Artist is not null)
{
<h5>
<a href="@artistLink" class="subtle-link" style="color: #598556">
Artist: @Count.Artist
@if (artistPercent >= 0.1)
{
<small> (@(artistPercentDisplay)%)</small>
}
</a>
</h5>
}
@if (Count.User is not null)
{
<h5>
<a href="@userLink" class="subtle-link">
User: @Count.User
</a>
</h5>
}
<LastfmLogo Link="@userLink" />
</div>
}
@code {
[Parameter]
public FullTrack Track { get; set; }
[Parameter]
public PlayCount Count { get; set; }
[Parameter]
public string Username { get; set; }
private string trackLink => $"https://www.last.fm/user/{Username}/library/music/{Track.Artists.First().Name}/_/{Track.Name}";
private string albumLink => $"https://www.last.fm/user/{Username}/library/music/{Track.Album.Artists.First().Name}/{Track.Album.Name}";
private string artistLink => $"https://www.last.fm/user/{Username}/library/music/{Track.Artists.First().Name}";
private string userLink => $"https://www.last.fm/user/{Username}";
private float trackPercent => Count.Track.HasValue && Count.User.HasValue ? (float) Count.Track.Value * 100 / Count.User.Value : 0f;
private float albumPercent => Count.Album.HasValue && Count.User.HasValue ? (float) Count.Album.Value * 100 / Count.User.Value : 0f;
private float artistPercent => Count.Artist.HasValue && Count.User.HasValue ? (float) Count.Artist.Value * 100 / Count.User.Value : 0f ;
private string trackPercentDisplay => string.Format("{0:#,##0.##}", trackPercent);
private string albumPercentDisplay => string.Format("{0:#,##0.##}", albumPercent);
private string artistPercentDisplay => string.Format("{0:#,##0.##}", artistPercent);
}

View File

@ -1,16 +0,0 @@
<div style="text-align: center">
<a href="https://sarsoo.xyz/about/" style="display: inline-block">
<img src="/andy.png"
alt="AP"
width="120px"
style="display: block;
margin-left: auto;
margin-right: auto;
padding: 8px">
</a>
</div>
@code {
}

View File

@ -1,15 +0,0 @@
@if (!string.IsNullOrWhiteSpace(Link))
{
<a href="@Link" target="_blank" class="spotify-logo" style="float: left">
<img src="/spotify_icon.png">
</a>
}
else
{
<img src="/spotify_icon.png" class="spotify-logo" style="float: left">
}
@code {
[Parameter]
public string Link { get; set; }
}

View File

@ -1,14 +0,0 @@
@using System.Net.Http
@using Microsoft.Extensions.Logging
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Selector
@using Selector.MAUI
@using Selector.MAUI.Shared
@using Selector.MAUI.Models
@using Selector.MAUI.Services
@using Radzen
@using Radzen.Blazor

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More