Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
f26c378b06 |
@ -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
|
||||
|
@ -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
|
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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"]
|
||||
|
||||
|
@ -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"]
|
||||
|
@ -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)
|
@ -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; }
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ namespace Selector.CLI
|
||||
|
||||
public enum Consumers
|
||||
{
|
||||
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter, MappingPersister
|
||||
AudioFeatures, AudioFeaturesCache, CacheWriter, Publisher, PlayCounter
|
||||
}
|
||||
|
||||
public class RedisOptions
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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())
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -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>
|
@ -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>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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());
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
||||
{
|
||||
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,10 +0,0 @@
|
||||
namespace Selector.MAUI;
|
||||
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
public MainPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +0,0 @@
|
||||
using System;
|
||||
namespace Selector.MAUI.Models;
|
||||
|
||||
public class LoginModel
|
||||
{
|
||||
public string Username { get; set; }
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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%
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -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%
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
@ -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
|
||||
{
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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>
|
@ -1,10 +0,0 @@
|
||||
using Foundation;
|
||||
|
||||
namespace Selector.MAUI;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
@ -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>
|
@ -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>
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,10 +0,0 @@
|
||||
using Foundation;
|
||||
|
||||
namespace Selector.MAUI;
|
||||
|
||||
[Register("AppDelegate")]
|
||||
public class AppDelegate : MauiUIApplicationDelegate
|
||||
{
|
||||
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||
}
|
||||
|
@ -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>
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Windows Machine": {
|
||||
"commandName": "MsixPackage",
|
||||
"nativeDebugging": false
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
@ -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 |
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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">
|
||||
© 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>*@
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 {
|
||||
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user