Compare commits

..

18 Commits

Author SHA1 Message Date
c59efaeff9 fix: adjust logging verbosity
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-28 06:31:17 +01:00
a1c9e3807b feat: add album and miscellaneous controllers with album mapping
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-28 05:18:03 +01:00
6736ce8c13 feat: add DTOs for basic endpoints 2026-01-28 05:17:11 +01:00
03940a99ba feat: orphan songs not present in the media directory 2026-01-28 05:16:17 +01:00
b46ab28615 fix: make sure albums and artists are bound to songs 2026-01-28 05:15:35 +01:00
3486b82879 fix: temporarily disable xml serialization
in it's current state xml endpoints are not supported, as no known good
way to serialize it exists
2026-01-28 05:13:42 +01:00
6a92451776 fix: stored procedure and database cleanup fix (also fixes file copying)
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-27 07:52:20 +01:00
17b8bafccd feat: make setup wizard copy a generic album image to music library path 2026-01-27 06:50:32 +01:00
0083539bac fix: assign unknown album to unknown artist, changes in migration logic
All checks were successful
Update changelog / changelog (push) Successful in 27s
2026-01-26 04:36:01 +01:00
d46a2573c4 feat: add library scanning mechanic 2026-01-26 04:35:12 +01:00
3a68531fb4 feat: add db cleanup command line argument 2026-01-26 04:29:36 +01:00
56273c2e3f feat: add workflow for release handling
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-25 20:56:19 +01:00
0b74cf2daa fix: add migrations for stored procedure and privacy field in playlists 2026-01-25 20:53:01 +01:00
ac2b6aba6e feat: add support for applying migrations automatically
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-17 01:07:05 +01:00
f5df949a4c fix: count runs, update versions if they differ
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-16 13:25:34 +01:00
a8f4afbfd8 fix: rewrite condition for admin counting
All checks were successful
Update changelog / changelog (push) Successful in 25s
2025-12-16 10:46:32 +01:00
defa00d0d3 feat: add setup wizard and proper seeder
All checks were successful
Update changelog / changelog (push) Successful in 26s
2025-12-16 04:02:42 +01:00
eebc5f1d6d fix: remove required from entities' id
also adds "Global" entity for storing global configuration
2025-12-16 04:01:45 +01:00
41 changed files with 2451 additions and 870 deletions

View File

@@ -3,3 +3,4 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
indent_style = tab

View File

@@ -0,0 +1,35 @@
# Credit: https://gitea.com/gitea/runner-images/src/branch/main/.gitea/workflows/release.yaml
name: Release new version
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
container: docker.io/thegeeklab/git-sv:2.0.9
steps:
- name: Install tools
run: |
apk add -q --update --no-cache nodejs
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-tags: true
fetch-depth: 0
- name: Create changelog
run: |
git sv current-version
git sv release-notes -t ${GITHUB_REF#refs/tags/} -o CHANGELOG.md
sed -i '1,2d' CHANGELOG.md # remove version
cat CHANGELOG.md
- name: Release
uses: https://github.com/akkuman/gitea-release-action@v1
with:
body_path: CHANGELOG.md
token: "${{ secrets.REPO_RW_TOKEN }}"

View File

@@ -34,4 +34,4 @@ jobs:
curl -s -X POST "https://gitea.7o7.cx/api/v1/repos/sherl/Shadow/issues" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null curl -s -X POST "https://gitea.7o7.cx/api/v1/repos/sherl/Shadow/issues" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
else else
curl -s -X PATCH "https://gitea.7o7.cx/api/v1/repos/sherl/Shadow/issues/$ISSUE_NUMBER" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null curl -s -X PATCH "https://gitea.7o7.cx/api/v1/repos/sherl/Shadow/issues/$ISSUE_NUMBER" -H "Authorization: token ${{ secrets.ISSUE_RW_TOKEN }}" -H "Content-Type: application/json" -d "$JSON_DATA" > /dev/null
fi fi

BIN
Assets/vinyl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,135 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Shadow.Data;
using Shadow.DTOs;
using Shadow.Entities;
using Shadow.Mapping;
using Shadow.Tools;
namespace Shadow.Controllers;
[ApiController]
[Route("rest")]
[Produces("application/json")]
public class AlbumController : ControllerBase
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
public AlbumController(ApplicationDbContext _db, GeneralUseHelpers _guhf)
{
db = _db;
guhf = _guhf;
}
[HttpGet("getCoverArt")]
[HttpGet("getCoverArt.view")]
[HttpPost("getCoverArt.view")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetCoverArt()
{
string thumbnailPath = db.Globals
.First(g => g.Key == "musicThumbnailPath").Value!;
IFileInfo fileInfo = new PhysicalFileProvider(thumbnailPath)
.GetFileInfo("default.png");
if (!fileInfo.Exists) return NotFound();
return PhysicalFile(fileInfo.PhysicalPath!, "image/png");
}
[HttpGet("getAlbumList2")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetAlbumList2()
{
// First, check if user is authorized
User? user = await guhf.GetUserFromParams(Request);
if (user == null)
{
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
Status = "failed",
error = new ErrorDTO()
}
});
}
List<AlbumViewShortDTO> albumsDTO = [];
List<Album> albums = db.Albums
.Where(a => a.State != 1)
.Include(a => a.Artist)
.Include(a => a.Songs)
.ToList();
foreach (Album al in albums)
{
albumsDTO.Add(
AlbumMapping.ToAlbumViewShort(al)
);
}
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
albumList2 = new AlbumList2DTO
{
album = albumsDTO
}
}
});
}
[HttpPost("getAlbumList2.view")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetAlbumList2Form()
{
// First, check if user is authorized
User? user = await guhf.GetUserFromForm(Request);
if (user == null)
{
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
Status = "failed",
error = new ErrorDTO()
}
});
}
List<AlbumViewShortDTO> albumsDTO = [];
List<Album> albums = db.Albums
.Where(a => a.State != 1)
.Include(a => a.Artist)
.Include(a => a.Songs)
.ToList();
foreach (Album al in albums)
{
albumsDTO.Add(
AlbumMapping.ToAlbumViewShort(al)
);
}
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
albumList2 = new AlbumList2DTO
{
album = albumsDTO
}
}
});
}
}

View File

@@ -1,311 +0,0 @@
using Shadow.Data;
using Shadow.Entities;
namespace Shadow.Controllers;
public class Cli
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
private readonly string[] args;
// TODO: Add "changeUser"
public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args)
{
db = _db;
guhf = _guhf;
args = _args;
}
public async Task<bool> Parse()
{
// Returns true if the program can finish execution.
bool exit = true;
// Check if anything has been passed
if (args.Length == 0 || args.FirstOrDefault() is null) {
return true;
}
switch (args[0].ToLower())
{
case "adduser":
AddUser();
break;
case "resetpassword":
ResetPassword();
break;
case "removeuser":
RemoveUser();
break;
case "help":
case "--help":
case "h":
case "-h":
case "/h":
case "/?":
case "?":
ShowHelp();
break;
default:
Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments.");
break;
}
return exit;
}
public void ShowHelp()
{
// Shown when "Shadow --help"/"Shadow help"/... is ran.
Console.WriteLine(
$"--- Shadow commandline utility ---\n" +
$"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n\n" +
$"Available commands:\n" +
$"- addUser [username] - create a new user,\n" +
$"- resetPassword [username] - reset a user's password,\n" +
$"- removeUser [username] - remove the user COMPLETELY.\n\n" +
$"Username is optional. If not provided, user will be prompted to enter it.\n" +
$"Running without specifying a command launches a Kestrel web server.\n\n" +
$"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow"
);
}
public void AddUser()
{
// Check if any cli arguments have been passed
// Both:
// Shadow addUser
// and
// Shadow addUser username
// are supported.
Console.WriteLine($"[Shadow] Add a new user");
if (args.Length == 2)
Console.WriteLine($" You will be promped to enter a password.");
else
Console.WriteLine($" You will be promped to enter a username and password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter a username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser != null) {
Console.WriteLine("Error! User with this name already exists in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
bool isUserAdmin = YesNoPrompt($" Should \"{username}\" be an administrator? [y/N]: ", false);
User newUser = new User
{
Name = username!,
NormalizedName = username!.ToLower(),
Password = password
};
db.Users.Add(newUser);
db.SaveChanges();
}
public void ResetPassword()
{
// Check if any cli arguments have been passed
// Both:
// Shadow resetPassword
// and
// Shadow resetPassword username
// are supported.
Console.WriteLine($"[Shadow] Reset password");
if (args.Length == 2)
Console.WriteLine($" You will be promped to enter a new password.");
else
Console.WriteLine($" You will be promped to enter a username and a new password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter a username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser == null)
{
Console.WriteLine("Error! User with this name does not exist in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm new password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
foundUser.Password = password;
db.SaveChanges();
}
public void RemoveUser()
{
// Check if any cli arguments have been passed
// Both:
// Shadow removeUser
// and
// Shadow removeUser username
// are supported.
Console.WriteLine($"[Shadow] Remove user");
if (args.Length == 2)
Console.WriteLine($" You will be promped to enter the password.");
else
Console.WriteLine($" You will be promped to enter the username and password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter the username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser == null)
{
Console.WriteLine("Error! User with this name does not exist in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
if (foundUser.Password != password)
{
Console.WriteLine($"Error! Entered password does not match that of \"{username}\"!");
return;
}
bool userDeletionConfirmation = YesNoPrompt($" Do you want to remove \"{username}\" completely?\n" +
$" This cannot be undone! [y/N]: ", false);
if (userDeletionConfirmation) {
// All playlists, interactions should be deleted as well.
List<AlbumInteraction> albumInteractions = db.AlbumInteractions
.Where(ai => ai.User == foundUser)
.ToList();
List<SongInteraction> songInteractions = db.SongInteractions
.Where(si => si.User == foundUser)
.ToList();
foreach(AlbumInteraction ai in albumInteractions)
db.AlbumInteractions.Remove(ai);
foreach (SongInteraction si in songInteractions)
db.SongInteractions.Remove(si);
db.Users.Remove(foundUser);
db.SaveChanges();
Console.WriteLine($"User \"{username}\" and all their data deleted successfully.");
}
else
Console.WriteLine("User not removed.");
}
public string ReadPassword(string prompt = " Enter password (will not be echoed back): ")
{
// https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/
string password = String.Empty;
ConsoleKeyInfo key;
bool exit = false;
Console.Write(prompt);
while (!exit) {
key = Console.ReadKey(true);
// Exit on enter
if (key.Key == ConsoleKey.Enter)
exit = true;
// Clear last character on backspace
else if (key.Key == ConsoleKey.Backspace && password.Length > 0)
password = password.Substring(0, (password.Length - 1));
// Append any other character
else
password += key.KeyChar;
}
Console.WriteLine();
return password;
}
public string? ReadName(string prompt = " Enter username: ")
{
Console.Write(prompt);
string? input = Console.ReadLine();
return input;
}
public bool YesNoPrompt(string prompt, bool? default_value = null)
{
// Checks if user input starts with "y", and if it does, returns true.
// Otherwise checks for "n". If both checks fail, and default_value is null,
// user will be asked repeatedly.
bool exit = false;
bool response = false;
Console.Write(prompt);
string? input = Console.ReadLine();
while (!exit)
{
if (input is not null && input.Length > 0)
{
if (input.ToLower().StartsWith("y"))
{
response = true;
exit = true;
}
else if (input.ToLower().StartsWith("n"))
{
response = false;
exit = true;
}
}
else if (default_value is not null)
{
response = (bool)default_value;
exit = true;
}
}
return response;
}
}

View File

@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data;
using Shadow.Entities;
using System.Text;
namespace Shadow.Controllers;
public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings)
{
private readonly ApplicationDbContext _db = db;
private readonly IConfiguration _appsettings = appsettings;
//async public Task<User?> GetUserFromEmail(string email)
//{
// return await _db.Users.FirstOrDefaultAsync(e => e.Email == email);
//}
//public string HashWithSHA512(string s)
//{
// using (var sha512 = SHA512.Create())
// {
// byte[] bytes = Encoding.ASCII.GetBytes(s);
// byte[] hash = sha512.ComputeHash(bytes);
// string hashstring = BitConverter.ToString(hash).Replace("-", "").ToLower();
// return hashstring;
// }
//}
}

View File

@@ -0,0 +1,153 @@
using Microsoft.AspNetCore.Mvc;
using Shadow.Data;
using Shadow.DTOs;
using Shadow.Entities;
using Shadow.Tools;
namespace Shadow.Controllers;
[ApiController]
[Route("rest")]
[Produces("application/json")]
public class MiscController : ControllerBase
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
public MiscController(ApplicationDbContext _db, GeneralUseHelpers _guhf)
{
db = _db;
guhf = _guhf;
}
[HttpGet("ping")]
[ProducesResponseType(200)]
public async Task<IActionResult> Ping()
{
User? user = await guhf.GetUserFromParams(Request);
// Wrong credentials?
if (user == null)
{
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
Status = "failed",
error = new ErrorDTO
{
code = 40,
message = "Wrong username or password."
}
}
});
}
return Ok(new ResponseWrapper {
SubsonicResponse = new SubsonicResponseDTO()
});
}
[HttpPost("ping.view")]
[ProducesResponseType(200)]
public async Task<IActionResult> PingForm()
{
User? user = await guhf.GetUserFromForm(Request);
// Wrong credentials?
if (user == null) {
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO {
Status = "failed",
error = new ErrorDTO {
code = 40,
message = "Wrong username or password."
}
}
});
}
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO()
});
}
[HttpGet("getUser")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetUser()
{
User? user = await guhf.GetUserFromParams(Request);
// Wrong credentials?
if (user == null)
{
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
Status = "failed",
error = new ErrorDTO
{
code = 40,
message = "Wrong username or password."
}
}
});
}
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
userPing = new UserPingDTO
{
username = user.Name,
adminRole = user.IsAdmin()
}
}
});
}
[HttpPost("getUser.view")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetUserForm()
{
User? user = await guhf.GetUserFromForm(Request);
// Wrong credentials?
if (user == null)
{
// Craft an error
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
Status = "failed",
error = new ErrorDTO
{
code = 40,
message = "Wrong username or password."
}
}
});
}
return Ok(new ResponseWrapper
{
SubsonicResponse = new SubsonicResponseDTO
{
userPing = new UserPingDTO
{
username = user.Name,
adminRole = user.IsAdmin()
}
}
});
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Shadow.Controllers;
using Shadow.Data;
using Shadow.Entities;
namespace Shadow.Controllers;
public class Seeder
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf)
{
db = _db;
guhf = _guhf;
}
public void Seed()
{
// TODO: Ensure [unknown album], [unknown artist] exist
// TODO: Force add a new user through CLI if no users exist
Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n");
}
}

11
DTOs/AlbumList2DTO.cs Normal file
View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
using System.Xml.Serialization;
namespace Shadow.DTOs;
public class AlbumList2DTO
{
[JsonPropertyName("album")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<AlbumViewShortDTO>? album { get; set; } = null;
}

51
DTOs/AlbumViewShortDTO.cs Normal file
View File

@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
using System.Xml.Serialization;
namespace Shadow.DTOs;
public class AlbumViewShortDTO
{
[JsonPropertyName("id")]
public string id { get; set; } = string.Empty;
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? name { get; set; } = null;
[JsonPropertyName("artist")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? artist { get; set; } = string.Empty;
[JsonPropertyName("artistId")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? artistId { get; set; } = string.Empty;
[JsonPropertyName("coverArt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? coverArt { get; set; } = "default.png";
[JsonPropertyName("songCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? songCount { get; set; } = null;
[JsonPropertyName("duration")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? duration { get; set; } = 0;
[JsonPropertyName("playCount")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? playCount { get; set; } = 0;
[JsonPropertyName("year")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? year { get; set; } = null;
[JsonPropertyName("genre")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? genre { get; set; } = null;
[JsonPropertyName("played")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? played { get; set; } = null;
}

13
DTOs/ErrorDTO.cs Normal file
View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using System.Xml.Serialization;
namespace Shadow.DTOs;
public class ErrorDTO
{
[JsonPropertyName("code")]
public int code { get; set; } = 0;
[JsonPropertyName("message")]
public string message { get; set; } = "Generic error";
}

9
DTOs/ResponseWrapper.cs Normal file
View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Shadow.DTOs;
public class ResponseWrapper
{
[JsonPropertyName("subsonic-response")]
required public SubsonicResponseDTO SubsonicResponse { get; set; }
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
using System.Xml.Linq;
using System.Xml.Serialization;
namespace Shadow.DTOs;
public class SubsonicResponseDTO
{
[JsonPropertyName("status")]
public string Status { get; set; } = "ok";
[JsonPropertyName("version")]
public string version { get; set; } = "1.15.0";
[JsonPropertyName("type")]
public string type { get; set; } = "shadow";
[JsonPropertyName("serverVersion")]
public string serverVersion { get; set; } = $"0.0.1 ({ThisAssembly.Git.Commit})";
[JsonPropertyName("openSubsonic")]
public bool openSubsonic { get; set; } = true;
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ErrorDTO? error { get; set; } = null;
[JsonPropertyName("albumList2")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public AlbumList2DTO? albumList2 { get; set; } = null;
[JsonPropertyName("user")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public UserPingDTO? userPing { get; set; } = null;
}

49
DTOs/UserPingDTO.cs Normal file
View File

@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace Shadow.DTOs;
public class UserPingDTO
{
[JsonPropertyName("username")]
public string? username { get; set; }
[JsonPropertyName("scrobblingEnabled")]
public bool scrobblingEnabled { get; set; } = false;
[JsonPropertyName("adminRole")]
public bool adminRole { get; set; } = false;
[JsonPropertyName("settingsRole")]
public bool settingsRole { get; set; } = false;
[JsonPropertyName("downloadRole")]
public bool downlaodRole { get; set; } = true;
[JsonPropertyName("uploadRole")]
public bool uploadRole { get; set; } = false;
[JsonPropertyName("playlistRole")]
public bool playlistRole { get; set; } = false;
[JsonPropertyName("coverArtRole")]
public bool coverArtRole { get; set; } = false;
[JsonPropertyName("commentRole")]
public bool commentRole { get; set; } = false;
[JsonPropertyName("podcastRole")]
public bool podcastRole { get; set; } = false;
[JsonPropertyName("streamRole")]
public bool streamRole { get; set; } = false;
[JsonPropertyName("jukeboxRole")]
public bool jukeboxRole { get; set; } = false;
[JsonPropertyName("shareRole")]
public bool shareRole { get; set; } = false;
[JsonPropertyName("videoConversionRole")]
public bool videoConversionRole { get; set; } = false;
}

View File

@@ -1,7 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shadow.Entities; using Shadow.Entities;
using System.Diagnostics.Metrics;
using System.Reflection.Emit;
namespace Shadow.Data; namespace Shadow.Data;
public class ApplicationDbContext : DbContext public class ApplicationDbContext : DbContext
@@ -17,6 +15,7 @@ public class ApplicationDbContext : DbContext
public DbSet<Artist> Artists => Set<Artist>(); public DbSet<Artist> Artists => Set<Artist>();
public DbSet<Genre> Genres => Set<Genre>(); public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreSong> GenreSongs => Set<GenreSong>(); public DbSet<GenreSong> GenreSongs => Set<GenreSong>();
public DbSet<Global> Globals => Set<Global>();
public DbSet<Image> Images => Set<Image>(); public DbSet<Image> Images => Set<Image>();
public DbSet<Playlist> Playlists => Set<Playlist>(); public DbSet<Playlist> Playlists => Set<Playlist>();
public DbSet<PlaylistSong> PlaylistSongs => Set<PlaylistSong>(); public DbSet<PlaylistSong> PlaylistSongs => Set<PlaylistSong>();
@@ -52,6 +51,9 @@ public class ApplicationDbContext : DbContext
builder.Entity<Genre>(g => { builder.Entity<Genre>(g => {
g.HasIndex(g => g.NormalizedName).IsUnique(); g.HasIndex(g => g.NormalizedName).IsUnique();
}); });
builder.Entity<Global>(c => {
c.HasIndex(c => c.Key).IsUnique();
});
builder.Entity<Playlist>(p => { builder.Entity<Playlist>(p => {
p.HasIndex(p => p.Uri).IsUnique(); p.HasIndex(p => p.Uri).IsUnique();
}); });

View File

@@ -1,7 +1,7 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Album public class Album
{ {
required public int Id { get; set; } public int Id { get; set; }
public required string Name { get; set; } public required string Name { get; set; }
public required string Uri { get; set; } public required string Uri { get; set; }
public int State { get; set; } = 0; public int State { get; set; } = 0;

View File

@@ -1,7 +1,7 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class AlbumInteraction public class AlbumInteraction
{ {
required public int Id { get; set; } public int Id { get; set; }
required public int AlbumId { get; set; } required public int AlbumId { get; set; }
required public int UserId { get; set; } required public int UserId { get; set; }
public DateTime? PlayDate { get; set; } = null; public DateTime? PlayDate { get; set; } = null;

View File

@@ -1,7 +1,7 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Artist public class Artist
{ {
required public int Id { get; set; } public int Id { get; set; }
required public string Name { get; set; } required public string Name { get; set; }
required public string NormalizedName { get; set; } required public string NormalizedName { get; set; }

View File

@@ -1,7 +1,7 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Genre public class Genre
{ {
required public int Id { get; set; } public int Id { get; set; }
required public string Name { get; set; } required public string Name { get; set; }
required public string NormalizedName { get; set; } required public string NormalizedName { get; set; }

View File

@@ -2,7 +2,7 @@ namespace Shadow.Entities;
public class GenreSong public class GenreSong
{ {
// Composite keys // Composite keys
required public int GenreId { get; set; } public int GenreId { get; set; }
required public int SongId { get; set; } required public int SongId { get; set; }
required public Genre Genre { get; set; } required public Genre Genre { get; set; }

7
Entities/Global.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace Shadow.Entities;
public class Global
{
public int Id { get; set; }
required public string Key { get; set; }
public string? Value { get; set; } = null;
}

View File

@@ -1,8 +1,9 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Image public class Image
{ {
required public int Id { get; set; } public int Id { get; set; }
required public string Uri { get; set; } required public string Uri { get; set; }
required public string Filetype { get; set; }
public int State { get; set; } public int State { get; set; }
public int? SongId { get; set; } = null; public int? SongId { get; set; } = null;

View File

@@ -1,11 +1,12 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Playlist public class Playlist
{ {
required public int Id { get; set; } public int Id { get; set; }
required public string Name { get; set; } required public string Name { get; set; }
required public string Uri { get; set; } required public string Uri { get; set; }
required public string Description { get; set; } required public string Description { get; set; }
required public int CreatorId { get; set; } // UserId? required public int CreatorId { get; set; } // UserId?
required public bool Private { get; set; } = true;
required public User Creator { get; set; } required public User Creator { get; set; }
public List<PlaylistUser> AuthorizedPlaylistUsers { get; set; } = []; public List<PlaylistUser> AuthorizedPlaylistUsers { get; set; } = [];

View File

@@ -1,7 +1,7 @@
namespace Shadow.Entities; namespace Shadow.Entities;
public class Radio public class Radio
{ {
required public int Id { get; set; } public int Id { get; set; }
required public string Name { get; set; } required public string Name { get; set; }
required public string NormalizedName { get; set; } required public string NormalizedName { get; set; }
public string? Homepage { get; set; } = null; public string? Homepage { get; set; } = null;

View File

@@ -20,8 +20,8 @@ public class Song
public int Index { get; set; } public int Index { get; set; }
public int? TrackNumber { get; set; } = null; public int? TrackNumber { get; set; } = null;
public int? DiscNumber { get; set; } = null; public int? DiscNumber { get; set; } = null;
required public int AlbumId { get; set; } public int AlbumId { get; set; }
required public int ArtistId { get; set; } public int ArtistId { get; set; }
public int? ImageId { get; set; } = null; public int? ImageId { get; set; } = null;
// Songs without an album entry shall default to "[Unnamed album]". // Songs without an album entry shall default to "[Unnamed album]".
@@ -31,6 +31,7 @@ public class Song
public List<GenreSong> GenreSongPair { get; set; } = []; public List<GenreSong> GenreSongPair { get; set; } = [];
public Image? Image { get; set; } = null; public Image? Image { get; set; } = null;
// TODO: Turn these into an enum.
public bool IsOk() => State == 0; public bool IsOk() => State == 0;
public bool IsOrphaned() => State == 1; public bool IsOrphaned() => State == 1;
public bool IsArchived() => State == 2; public bool IsArchived() => State == 2;

27
Mapping/AlbumMapping.cs Normal file
View File

@@ -0,0 +1,27 @@
using Shadow.DTOs;
using Shadow.Entities;
namespace Shadow.Mapping;
public static class AlbumMapping
{
public static AlbumViewShortDTO ToAlbumViewShort(Album album)
{
AlbumViewShortDTO dto = new AlbumViewShortDTO
{
id = $"{album.Id}",
name = album.Name,
artist = album.Artist?.Name ?? "[Unknown Artist]",
artistId = $"{album.ArtistId}",
coverArt = "default.png",
songCount = album.Songs.Count,
duration = album.Songs.Sum(s => s.Duration),
playCount = 0,
year = 0,
genre = "unknown",
// played = "never"
};
return dto;
}
}

View File

@@ -0,0 +1,31 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Shadow.Data;
#nullable disable
namespace Shadow.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("00000000000000_StoredProcedure")]
partial class _00000000000000_StoredProcedure
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace Shadow.Migrations;
public partial class _00000000000000_StoredProcedure : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE OR REPLACE PROCEDURE song_cleanup()
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM "Songs"
WHERE "State" = 1;
COMMIT;
END;
$$;
""");
migrationBuilder.Sql("""
CREATE OR REPLACE PROCEDURE force_rescan()
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE "Globals"
SET "Value" = '0'
WHERE "Key" = 'libraryState';
COMMIT;
END;
$$;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DROP PROCEDURE IF EXISTS song_cleanup();
""");
migrationBuilder.Sql("""
DROP PROCEDURE IF EXISTS force_rescan();
""");
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -12,7 +12,7 @@ using Shadow.Data;
namespace Shadow.Migrations namespace Shadow.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20251209000751_InitialMigration")] [Migration("20260125172515_InitialMigration")]
partial class InitialMigration partial class InitialMigration
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -20,7 +20,7 @@ namespace Shadow.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("ProductVersion", "10.0.1")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -144,6 +144,29 @@ namespace Shadow.Migrations
b.ToTable("GenreSongs"); b.ToTable("GenreSongs");
}); });
modelBuilder.Entity("Shadow.Entities.Global", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("Globals");
});
modelBuilder.Entity("Shadow.Entities.Image", b => modelBuilder.Entity("Shadow.Entities.Image", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -152,6 +175,10 @@ namespace Shadow.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Filetype")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("SongId") b.Property<int?>("SongId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -189,6 +216,9 @@ namespace Shadow.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("Private")
.HasColumnType("boolean");
b.Property<string>("Uri") b.Property<string>("Uri")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -602,6 +632,7 @@ namespace Shadow.Migrations
b.Navigation("SongInteractions"); b.Navigation("SongInteractions");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@@ -40,6 +40,20 @@ namespace Shadow.Migrations
table.PrimaryKey("PK_Genres", x => x.Id); table.PrimaryKey("PK_Genres", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "Globals",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Globals", x => x.Id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Users", name: "Users",
columns: table => new columns: table => new
@@ -86,7 +100,8 @@ namespace Shadow.Migrations
Name = table.Column<string>(type: "text", nullable: false), Name = table.Column<string>(type: "text", nullable: false),
Uri = table.Column<string>(type: "text", nullable: false), Uri = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false), Description = table.Column<string>(type: "text", nullable: false),
CreatorId = table.Column<int>(type: "integer", nullable: false) CreatorId = table.Column<int>(type: "integer", nullable: false),
Private = table.Column<bool>(type: "boolean", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
@@ -247,6 +262,7 @@ namespace Shadow.Migrations
Id = table.Column<int>(type: "integer", nullable: false) Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Uri = table.Column<string>(type: "text", nullable: false), Uri = table.Column<string>(type: "text", nullable: false),
Filetype = table.Column<string>(type: "text", nullable: false),
State = table.Column<int>(type: "integer", nullable: false), State = table.Column<int>(type: "integer", nullable: false),
SongId = table.Column<int>(type: "integer", nullable: true) SongId = table.Column<int>(type: "integer", nullable: true)
}, },
@@ -346,6 +362,12 @@ namespace Shadow.Migrations
table: "GenreSongs", table: "GenreSongs",
column: "SongId"); column: "SongId");
migrationBuilder.CreateIndex(
name: "IX_Globals_Key",
table: "Globals",
column: "Key",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_Images_SongId", name: "IX_Images_SongId",
table: "Images", table: "Images",
@@ -421,6 +443,9 @@ namespace Shadow.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "GenreSongs"); name: "GenreSongs");
migrationBuilder.DropTable(
name: "Globals");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Images"); name: "Images");

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Shadow.Controllers;
using Shadow.Data; using Shadow.Data;
using Shadow.Tools;
using System.Reflection; using System.Reflection;
using System.Xml;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -17,7 +18,10 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
builder.Services.AddControllers(options => builder.Services.AddControllers(options =>
{ {
// Add XML serializer // Add XML serializer
options.OutputFormatters.Add(new XmlSerializerOutputFormatter()); // options.RespectBrowserAcceptHeader = true;
// options.OutputFormatters.Add(new XmlSerializerOutputFormatter(
// new XmlWriterSettings { OmitXmlDeclaration = true }
// ));
}).AddJsonOptions(options => }).AddJsonOptions(options =>
{ {
// Pretty-print JSON // Pretty-print JSON
@@ -66,18 +70,20 @@ builder.Services.AddSwaggerGen(options =>
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
}); });
builder.Services.AddHttpLogging();
var app = builder.Build(); var app = builder.Build();
bool shutdown = false;
bool scanNeeded = false;
if (args.FirstOrDefault() is not null) if (args.FirstOrDefault() is not null)
{ {
// Handle CLI if arguments have been passed. // Handle CLI if arguments have been passed.
bool shutdown = false;
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
{ {
ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>();
Cli cli = new(db, guhf, args); Cli cli = new(db, guhf, args);
shutdown = await cli.Parse(); shutdown = cli.Parse();
} }
if (shutdown) return; if (shutdown) return;
} }
@@ -87,9 +93,10 @@ using (IServiceScope scope = app.Services.CreateScope())
ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>();
Seeder seeder = new(db, guhf); Seeder seeder = new(db, guhf);
seeder.Seed(); shutdown = seeder.Seed();
if (!shutdown) scanNeeded = await seeder.ScanPrefetchAsync();
} }
if (shutdown) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -97,6 +104,7 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseHttpLogging();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();

View File

@@ -19,19 +19,21 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitInfo" Version="3.6.0"> <PackageReference Include="GitInfo" Version="3.6.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Npgsql" Version="10.0.0" /> <PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="SharpExifTool" Version="13.32.0.1" /> <PackageReference Include="SharpExifTool" Version="13.32.0.1" />
<PackageReference Include="SkiaSharp" Version="3.119.1" /> <PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
@@ -41,10 +43,4 @@
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
<Folder Include="Mapping\" />
<Folder Include="DTOs\" />
</ItemGroup>
</Project> </Project>

567
Tools/Cli.cs Normal file
View File

@@ -0,0 +1,567 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data;
using Shadow.Entities;
namespace Shadow.Tools;
public class Cli
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
private readonly string[] args;
// TODO: Add "changeUser", "fullRescan", "destructiveRescan", "healthcheck"
// TODO: Make this async
public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args)
{
db = _db;
guhf = _guhf;
args = _args;
}
public bool Parse()
{
// Returns true if the program can finish execution.
bool exit = true;
// Check if anything has been passed
if (args.Length == 0 || args.FirstOrDefault() is null) {
return true;
}
switch (args[0].ToLower())
{
case "adduser":
case "--adduser":
case "--add-user":
if (!Seeder.EnsureMigrations(db)) return true;
AddUser();
break;
case "resetpassword":
case "--resetpassword":
case "--reset-password":
if (!Seeder.EnsureMigrations(db)) return true;
ResetPassword();
break;
case "removeuser":
case "--removeuser":
case "--remove-user":
if (!Seeder.EnsureMigrations(db)) return true;
RemoveUser();
break;
case "help":
case "--help":
case "h":
case "-h":
case "/h":
case "/?":
case "?":
// Help should be shown without forcing migrations.
ShowHelp();
break;
case "setupwizard":
case "--setupwizard":
case "--setup-wizard":
case "setup":
case "--setup":
case "configure":
case "--configure":
if (!Seeder.EnsureMigrations(db)) return true;
SetupWizard();
break;
case "clear":
case "cleanup":
case "cleardb":
case "cleanupdb":
case "--clear":
case "--cleanup":
if (!Seeder.EnsureMigrations(db)) return true;
CleanUpDb();
break;
default:
Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments.");
break;
}
return exit;
}
public void ShowHelp()
{
// Shown when "Shadow --help"/"Shadow help"/... is ran.
Console.WriteLine(
$"--- Shadow commandline utility ---\n" +
$"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n" +
$"\n" +
$"Available commands:\n" +
$"\n" +
$"=== User management ===\n" +
$"- addUser [username] - create a new user,\n" +
$"- resetPassword [username] - reset a user's password,\n" +
$"- removeUser [username] - remove the user COMPLETELY.\n" +
$"\n" +
$"=== Server maintenance ===\n" +
$"- setupWizard - configure library and thumbnail location on disk,\n" +
$"- cleanup - cleanup database from orphaned songs/albums/artists.\n" +
$"\n" +
$"[field] means the field is optional. If not provided, user will be prompted to enter it.\n" +
$"Running without specifying a command launches a Kestrel web server.\n" +
$"\n" +
$"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow"
);
}
public void AddUser()
{
// Check if any cli arguments have been passed
// Both:
// Shadow addUser
// and
// Shadow addUser username
// are supported.
Console.WriteLine($"[Shadow] Add a new user");
if (args.Length == 2)
Console.WriteLine($" You will be promped to enter a password.");
else
Console.WriteLine($" You will be promped to enter a username and password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter a username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser != null) {
Console.WriteLine("Error! User with this name already exists in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
bool isUserAdmin = YesNoPrompt($" Should \"{username}\" be an administrator? [y/N]: ", false);
User newUser = new User
{
Name = username!,
NormalizedName = username!.ToLower(),
Password = password,
Role = isUserAdmin ? 0 : 1 // 0 = admin
};
db.Users.Add(newUser);
db.SaveChanges();
}
public void ResetPassword()
{
// Check if any cli arguments have been passed
// Both:
// Shadow resetPassword
// and
// Shadow resetPassword username
// are supported.
Console.WriteLine($"[Shadow] Reset password");
if (args.Length == 2)
Console.WriteLine($" You will be promped to enter a new password.");
else
Console.WriteLine($" You will be promped to enter a username and a new password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter a username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser == null)
{
Console.WriteLine("Error! User with this name does not exist in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm new password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
foundUser.Password = password;
db.SaveChanges();
}
public void RemoveUser()
{
// Check if any cli arguments have been passed
// Both:
// Shadow removeUser
// and
// Shadow removeUser username
// are supported.
Console.WriteLine($"[Shadow] Remove user");
if (args.Length == 2)
Console.WriteLine($" You will be prompted to enter the password.");
else
Console.WriteLine($" You will be prompted to enter the username and password.");
string? username = null;
if (args.Length != 2)
{
while (username is null || username == String.Empty)
username = ReadName(" Please enter the username: ");
}
else
username = args[1];
// Check if user by this name exists in DB
User? foundUser = db.Users
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
if (foundUser == null)
{
Console.WriteLine("Error! User with this name does not exist in the database!");
return;
}
string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm password: ");
if (!password.Equals(passwordConfirmation))
{
Console.WriteLine("Error! Passwords do not match. Please try again.");
return;
}
if (foundUser.Password != password)
{
Console.WriteLine($"Error! Entered password does not match that of \"{username}\"!");
return;
}
bool userDeletionConfirmation = YesNoPrompt($" Do you want to remove \"{username}\" completely?\n" +
$" This cannot be undone! [y/N]: ", false);
if (userDeletionConfirmation) {
// All playlists, interactions should be deleted as well.
List<AlbumInteraction> albumInteractions = db.AlbumInteractions
.Where(ai => ai.User == foundUser)
.ToList();
List<SongInteraction> songInteractions = db.SongInteractions
.Where(si => si.User == foundUser)
.ToList();
foreach(AlbumInteraction ai in albumInteractions)
db.AlbumInteractions.Remove(ai);
foreach (SongInteraction si in songInteractions)
db.SongInteractions.Remove(si);
db.Users.Remove(foundUser);
db.SaveChanges();
Console.WriteLine($"User \"{username}\" and all their data deleted successfully.");
}
else
Console.WriteLine("User not removed.");
}
public bool SetupWizard()
{
// Returns true if setup successful. Otherwise false.
bool success = false;
Console.WriteLine("[Shadow] Setup wizard\n" +
" Welcome to Shadow's setup wizard. Here, you can customize:\n" +
" - Shadow's music library path, used to scan for audio files,\n" +
" - Shadow's image thumbnails path, used to provide cover art extracted from media.\n" +
" Finally, you will be asked if you want your changes saved.\n");
// TODO: do a healthcheck here
Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath");
Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath");
bool configurationExists = musicLibraryPath is not null || musicThumbnailPath is not null;
if (configurationExists)
{
Console.WriteLine(" Found existing configuration:");
if (musicLibraryPath is not null)
Console.WriteLine($" - for music library: {musicLibraryPath.Value}");
if (musicThumbnailPath is not null)
Console.WriteLine($" - for image thumbnails: {musicThumbnailPath.Value}");
Console.WriteLine(" They will be OVERWRITTEN if you'll commit your changes.\n");
}
else
{
Console.WriteLine(" No existing configuration found.\n");
}
string newMusicLibraryPath = DefaultPrompt("Please enter your new music library path", musicLibraryPath?.Value).TrimEnd(['\\', '/']);
string newMusicThumbnailPath = DefaultPrompt("Please enter your new image thumbnail path", musicThumbnailPath?.Value ?? Path.Combine(newMusicLibraryPath, ".shadow")).TrimEnd(['\\', '/']);
bool confirmed = YesNoPrompt("Are you sure you want:\n" +
$"- \"{newMusicLibraryPath}\" as your new music library path,\n" +
$"- \"{newMusicThumbnailPath}\" as your new image thumbnail path?\n" +
$"Please answer yes or no [y/N]: ", false);
if (!confirmed)
{
Console.WriteLine("Changes not saved.");
success = false;
}
else
{
Console.Write("Saving changes... ");
musicLibraryPath ??= new Global() { Key = "musicLibraryPath" };
musicThumbnailPath ??= new Global() { Key = "musicThumbnailPath" };
musicLibraryPath.Value = newMusicLibraryPath;
musicThumbnailPath.Value = newMusicThumbnailPath;
System.IO.Directory.CreateDirectory(newMusicLibraryPath);
System.IO.Directory.CreateDirectory(newMusicThumbnailPath);
// Try to find the Assets directory automatically
string currentPath = AppContext.BaseDirectory;
for (int i = 0; i < 5; i++)
{
if (Directory.GetFiles(currentPath, "Shadow.slnx")
.FirstOrDefault() != null)
{
File.Copy(
Path.Combine(currentPath, "Assets", "vinyl.png"),
Path.Combine(newMusicThumbnailPath, "default.png"),
true
);
break;
}
currentPath = Path.Combine(currentPath, "..");
if (i == 5) Console.WriteLine("\n" +
"[Error] Could not determine content root path. \n" +
"Please place Assets/vinyl.png in your thumbnail path directory manually\n" +
"and rename it to default.png.");
}
// UpdateRange can both Add and Update rows.
db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath);
db.SaveChanges();
Console.WriteLine("success!");
success = true;
Console.WriteLine("\n" +
"Tip: On your next run of Shadow, a web server will start.\n" +
"If you ever need to enter setup wizard again, use `Shadow setupWizard`.\n" +
"For a complete list of available commands use `Shadow help`.\n");
}
return success;
}
public void CleanUpDb()
{
// Ask user here whether he/she wants to
// clear the DB from dangling records (orphaned songs/albums/artists).
int rowsAffected = 0;
// Retrieve orphaned songs
List<Song> orphanedSongs = db.Songs
.Where(s => s.State == 1)
.Include(s => s.Album)
.ToList();
// Ask if it's alright to remove them
// and related listening data permanently
if (orphanedSongs.Count != 0)
{
bool songConsent = false;
songConsent = YesNoPrompt($"Found {orphanedSongs.Count} orphaned songs. Remove them? [y/N]: ", false);
if (songConsent)
{
foreach (Song s in orphanedSongs)
{
// Remove song interactions
List<SongInteraction> interactions = db.SongInteractions.Where(si => si.Song == s).ToList();
db.SongInteractions.RemoveRange(interactions);
// Remove song from playlists
List<PlaylistSong> playlists = db.PlaylistSongs.Where(ps => ps.Song == s).ToList();
db.PlaylistSongs.RemoveRange(playlists);
// Remove song from albums
Album album = s.Album;
album.Songs.Remove(s);
if (album.Songs.Count == 0) album.State = 1; // Set album state to orphaned.
// TODO: Remove song images if not used by any other resource
// ...
db.SaveChanges();
}
// Perform cleanup with stored procedure
db.Database.ExecuteSqlRaw("CALL song_cleanup()");
rowsAffected += orphanedSongs.Count;
}
}
else Console.WriteLine("No orphaned songs found.");
// Rinse and repeat
// Retrieve orphaned albums
List<Album> orphanedAlbums = db.Albums.Where(a => a.State == 1).ToList();
if (orphanedAlbums.Count != 0)
{
bool albumConsent = false;
albumConsent = YesNoPrompt($"Found {orphanedAlbums.Count} orphaned albums. Remove them? [y/N]: ", false);
if (albumConsent)
{
db.Albums.RemoveRange(orphanedAlbums);
rowsAffected += orphanedAlbums.Count;
db.SaveChanges();
}
}
else Console.WriteLine("No orphaned albums found.");
// Retrieve orphaned artists (artists with no songs AND albums)
List<Artist> orphanedArtists = db.Artists
.Where(a => a.Songs.Count == 0 && a.Albums.Count == 0)
.ToList();
Artist? unknownArtist = db.Artists
.FirstOrDefault(a => a.NormalizedName == "[unknown artist]");
// Account for the [Unknown Artist],
// which is a meta-artist and shall
// not be slated for removal.
// Can be null only if this command
// is ran before seeding.
if (unknownArtist != null) orphanedArtists.Remove(unknownArtist);
if (orphanedArtists.Count != 0)
{
bool artistConsent = false;
artistConsent = YesNoPrompt($"Found {orphanedArtists.Count} orphaned artists. Remove them? [y/N]: ", false);
if (artistConsent)
{
db.Artists.RemoveRange(orphanedArtists);
rowsAffected += orphanedArtists.Count;
}
}
else Console.WriteLine("No orphaned artists found.");
Console.WriteLine($"{rowsAffected} entries affected.");
db.SaveChanges();
}
public static string ReadPassword(string prompt = " Enter password (will not be echoed back): ")
{
// https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/
string password = String.Empty;
ConsoleKeyInfo key;
bool exit = false;
Console.Write(prompt);
while (!exit) {
key = Console.ReadKey(true);
// Exit on enter
if (key.Key == ConsoleKey.Enter)
exit = true;
// Clear last character on backspace
else if (key.Key == ConsoleKey.Backspace && password.Length > 0)
password = password.Substring(0, (password.Length - 1));
// Append any other character
else
password += key.KeyChar;
}
Console.WriteLine();
return password;
}
public static string? ReadName(string prompt = " Enter username: ")
{
Console.Write(prompt);
string? input = Console.ReadLine();
return input;
}
public static bool YesNoPrompt(string prompt, bool? default_value = null)
{
// Checks if user input starts with "y", and if it does, returns true.
// Otherwise checks for "n". If both checks fail, and default_value is null,
// user will be asked repeatedly.
bool exit = false;
bool response = false;
string? input = null;
while (!exit)
{
Console.Write(prompt);
input = Console.ReadLine();
if (input is not null && input.Length > 0)
{
if (input.ToLower().StartsWith("y"))
{
response = true;
exit = true;
}
else if (input.ToLower().StartsWith("n"))
{
response = false;
exit = true;
}
}
else if (default_value is not null)
{
response = (bool)default_value;
exit = true;
}
}
return response;
}
public static string DefaultPrompt(string prompt, string? default_value = null)
{
// Prompt the user repeatedly for answer. If default value
// is specified, an enter will be treated as if the user
// entered the default value.
string response = String.Empty;
string? input;
while (response.Trim() == String.Empty)
{
Console.Write($"{prompt}" + (default_value is not null ? $" [{default_value}]" : "") + ": ");
input = Console.ReadLine();
if (input is not null && input.Length > 0)
response = input;
else if (default_value is not null)
response = default_value;
}
return response;
}
}

View File

@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data;
using Shadow.Entities;
using System.Text;
using System.Xml.Serialization;
namespace Shadow.Tools;
public class GeneralUseHelpers(ApplicationDbContext? db = null, IConfiguration? appsettings = null)
{
private readonly ApplicationDbContext? _db = db;
private readonly IConfiguration? _appsettings = appsettings;
//async public Task<User?> GetUserFromEmail(string email)
//{
// return await _db.Users.FirstOrDefaultAsync(e => e.Email == email);
//}
//public string HashWithSHA512(string s)
//{
// using (var sha512 = SHA512.Create())
// {
// byte[] bytes = Encoding.ASCII.GetBytes(s);
// byte[] hash = sha512.ComputeHash(bytes);
// string hashstring = BitConverter.ToString(hash).Replace("-", "").ToLower();
// return hashstring;
// }
//}
/// <summary>
/// Quick and dirty Dictionary&lt;string, string&gt; to JSON serializer
/// </summary>
/// <param name="dict">Dictionary with keypair of two strings</param>
/// <returns>Minified JSON</returns>
public static string DictAsJson(Dictionary<string, string> dict)
{
string resultJson = String.Empty;
foreach (string key in dict.Keys)
{
string cleanKey = key.Replace("\"", "\\\""); // "a"b" -> "a\"b"
string cleanValue = dict[key].Replace("\"", "\\\"");
resultJson += $"\"{cleanKey}\": \"{cleanValue}\", " // "key": "val",<space>
.Replace(@"\", @"\\"); // a\b -> a\\b
}
return "{" + resultJson[..^2] + "}";
}
public async Task<User?> GetUserFromForm(HttpRequest request)
{
User? user = null;
try
{
string username = request.Form["u"]!;
string saltedPassword = request.Form["t"]!;
string sesame = request.Form["s"]!;
User? foundUser = _db?.Users
.FirstOrDefault(u => u.NormalizedName == username.ToLower());
if (foundUser == null)
return user;
string resaltedPassword = MetadataExtractor.GetStringMD5($"{foundUser.Password}{sesame}");
if (resaltedPassword == saltedPassword)
user = foundUser;
}
catch
{
user = null;
}
return user;
}
public async Task<User?> GetUserFromParams(HttpRequest request)
{
User? user = null;
try
{
string username = request.Query["u"]!;
string saltedPassword = request.Query["t"]!;
string sesame = request.Query["s"]!;
User foundUser = _db!.Users
.FirstOrDefault(u => u.NormalizedName == username.ToLower())!;
string resaltedPassword = MetadataExtractor.GetStringMD5($"{foundUser.Password}{sesame}");
if (resaltedPassword == saltedPassword)
user = foundUser;
}
catch
{
user = null;
}
return user;
}
}

144
Tools/LibraryWatcher.cs Normal file
View File

@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data;
using Shadow.Entities;
using System;
using System.IO;
namespace Shadow.Tools;
public class LibraryWatcher(string watchPath, string[] excludedPaths, ApplicationDbContext dbContext)
{
private readonly string libraryPath = watchPath;
private readonly string[] excludedPaths = excludedPaths;
private readonly ApplicationDbContext db = dbContext;
private readonly GeneralUseHelpers guhf = new();
/// <summary>
/// Returns a sorted list of paths to all files in a directory, recursively.
/// </summary>
/// <param name="directory">Path to directory</param>
/// <returns>Sorted list of filepaths</returns>
public async Task<List<string>> GetFilesRecursivelyAsync(string directory)
{
string[] allowedExtensions = [".flac", ".m4a", ".mp3", ".ogg", ".wav"];
try
{
List<string> files =
Directory.GetFiles(directory, "*", SearchOption.AllDirectories)
.Where(file => allowedExtensions.Any(file.ToLower().EndsWith))
.ToList();
files.Sort();
return files;
}
catch (DirectoryNotFoundException)
{
Console.WriteLine($"[Error] Directory \"{directory}\" does not exist!\n" +
" Please create it manually, or use `Shadow setupWizard`.");
throw new DirectoryNotFoundException();
}
}
/// <summary>
/// Return all multimedia content inside of library
/// </summary>
/// <returns>List of multimedia filepaths</returns>
public async Task<List<string>> GetAllMultimediaAsync()
{
// List files in cache
// Note: currently, the only excluded path from scanning is the thumbnail cache.
// This might change in the future.
List<string> cacheFiles = await GetFilesRecursivelyAsync(excludedPaths[0]);
// List files in library excluding cache
List<string> libraryContent = await GetFilesRecursivelyAsync(libraryPath);
List<string> libraryMultimedia = libraryContent.Except(cacheFiles).ToList();
return libraryMultimedia;
}
/// <summary>
/// Scan the library in its entirety
/// </summary>
/// <returns></returns>
public async Task<List<string>> PerformFullScanAsync()
{
Console.WriteLine("Performing full library scan...");
// Current library state as present in database
List<Song> currentLibraryMedia = await db.Songs
.Where(s => s.State == 0)
.ToListAsync();
// Updated library state
List<Song> updatedSongList = [];
List<string> newMultimediaPathNames = await GetAllMultimediaAsync();
foreach (string filepath in newMultimediaPathNames)
{
Console.WriteLine(filepath);
Dictionary<string, string> fileInfo = await MetadataExtractor.ExtractAsync(filepath);
// Pretend we are doing parsing here...
Console.WriteLine(GeneralUseHelpers.DictAsJson(fileInfo));
Song? songInDb = db.Songs
.Include(s => s.Album)
.FirstOrDefault(s => s.Uri == fileInfo["_shadow:fileHash"]);
if (songInDb != null)
{
// Don't parse the song
Console.WriteLine("Skipping song as it already exists in database...");
// But update it's location in case it has been moved
songInDb.Filepath = filepath;
// And state in case it has been reinstated
songInDb.State = 0; // Set non-orphaned state
songInDb.Album.State = 0; // -||-
db.Update(songInDb); // Is this necessary?
await db.SaveChangesAsync();
// Afterwards include it in the updated song list
updatedSongList.Add(songInDb);
}
else
{
// A new song? Parse it and add to DB
Song? newSong = MediaParser.CreateSong(db, fileInfo);
// Sanity check
if (newSong != null)
updatedSongList.Add(newSong);
}
}
Console.WriteLine($"Full scan complete! Processed {newMultimediaPathNames.Count} files.");
List<Song> orphanedSongs = currentLibraryMedia
.Except(updatedSongList)
.ToList();
Console.WriteLine($"Detected {orphanedSongs.Count} new orphaned songs");
foreach (Song s in orphanedSongs)
{
Song dbSong = db.Songs
.Include(d => d.Artist)
.First(d => d.Id == s.Id);
Console.WriteLine($"- {dbSong.Title} by {dbSong.Artist.Name} (previous path: {dbSong.Filepath})");
dbSong.State = 1;
await db.SaveChangesAsync();
}
Console.WriteLine();
// Update state inside of DB
string updatedLibraryState = MetadataExtractor.GetStringMD5(string.Join("\n", newMultimediaPathNames));
Global lastLibraryState = db.Globals.FirstOrDefault(g => g.Key == "libraryState")
?? new() { Key = "libraryState"};
lastLibraryState.Value = updatedLibraryState;
db.Update(lastLibraryState);
await db.SaveChangesAsync();
return newMultimediaPathNames;
}
}

129
Tools/MediaParser.cs Normal file
View File

@@ -0,0 +1,129 @@
using Microsoft.VisualBasic.FileIO;
using Shadow.Data;
using Shadow.Entities;
using System.Security.Cryptography;
namespace Shadow.Tools;
public static class MediaParser
{
/// <summary>
/// Generate a random hex string (with length 32 by default)
/// </summary>
/// <param name="length">Optional: hexstring length</param>
/// <returns>A hexstring of given length</returns>
public static string HexStr(int length = 32)
{
return RandomNumberGenerator.GetHexString(length).ToLower();
}
/// <summary>
/// Get metadata content opportunistically
/// </summary>
/// <param name="metadata">Dictionary to search in</param>
/// <param name="searchStrings">Keywords to search for</param>
/// <returns>Retrieved value (string) on success, otherwise null</returns>
public static string? GetAny(Dictionary<string, string> metadata, List<string> searchStrings)
{
foreach (string searchString in searchStrings)
{
if (metadata.TryGetValue(searchString, out string? value) && !string.IsNullOrEmpty(value))
return value;
}
return null;
}
/// <summary>
/// Map exiftool metadata to Song entity.
/// </summary>
/// <param name="db">Database context</param>
/// <param name="exif">ExifTool metadata</param>
/// <returns>New Song entity, or null if song already exists in db</returns>
public static Song? CreateSong(ApplicationDbContext db, Dictionary<string, string> exif)
{
// First of all, check if song already exists in db
string uri = GetAny(exif, ["_shadow:fileHash"])
?? throw new Exception("Fatal error: could not get file hash!");
if (db.Songs.FirstOrDefault(s => s.Uri == uri) != null) return null;
// If not, extract exif data
string title = GetAny(exif, [
"ItemList:Title", // iTunes m4a
"ItemList:SortName", // iTunes m4a
"Vorbis:Title", // Bandcamp ogg
"ID3v2_3:Title", // Generic mp3/wav ID3 v2.3.0
])
?? Path.Combine(exif["System:Directory"], exif["System:FileName"]);
string filepath = Path.GetFullPath(
Path.Combine(exif["System:Directory"], exif["System:FileName"])
); // TODO: bulletproof this
string filetype = exif["File:FileType"].ToLower();
// Album/artist related
string artistName = GetAny(exif, [
"ItemList:Artist", // iTunes m4a
"ItemList:AlbumArtist", // iTunes m4a
"Vorbis:Artist", // Bandcamp m4a
"Vorbis:Albumartist", // Bandcamp m4a
"ID3v2_3:Artist", // Generic mp3/wav ID3 v2.3.0
]) ?? "[Unknown Artist]"; // this is a weak line of defense against deliberately crafted
string albumName = GetAny(exif, [
"ItemList:Album", // iTunes m4a
"Vorbis:Album", // Bandcamp m4a
"ID3v2_3:Album", // Generic mp3/wav ID3 v2.3.0
]) ?? "[Unknown Album]"; // again, weak line of defense
// TODO: Try and find genres
// Try to find relevant artists and albums
Artist artist = db.Artists
.FirstOrDefault(a => a.NormalizedName == artistName.ToLower())
?? new Artist
{
Name = artistName,
NormalizedName = artistName.ToLower()
};
Album album = db.Albums
.FirstOrDefault(a => a.Name == albumName && a.Artist == artist)
?? new Album
{
Name = albumName,
Uri = HexStr(),
Artist = artist
};
Song song = new()
{
Title = title,
Uri = uri,
Filepath = filepath,
Filetype = filetype,
Album = album,
Artist = artist
};
try
{
// Is Update() safe here?
db.Artists.Update(artist);
db.Albums.Update(album);
db.SaveChanges();
db.Songs.Update(song);
artist.Albums.Add(album);
artist.Songs.Add(song);
db.SaveChanges();
}
catch (Exception e)
{
Console.WriteLine($"[Error: MediaParser] Failed to extract metadata from {filepath}:\n" +
$"{e}");
}
return song;
}
}

View File

@@ -0,0 +1,67 @@
using SharpExifTool;
using System.Security.Cryptography;
namespace Shadow.Tools;
public static class MetadataExtractor
{
private readonly static ExifTool exifTool = new();
private readonly static GeneralUseHelpers guhf = new();
public async static Task<Dictionary<string, string>> ExtractAsync(string fullPath)
{
// Get all relevant metadata
Dictionary<string, string> fileMetadata = new(await
exifTool.ExtractAllMetadataAsync(fullPath));
// Add in a MD5 hint
string md5sum = await GetFileMD5Async(fullPath);
fileMetadata?.Add("_shadow:fileHash", md5sum);
return fileMetadata ?? [];
}
/// <summary>
/// Compute MD5 checksum of a file
/// </summary>
/// <param name="fullPath">Input file absolute path</param>
/// <returns>MD5 hexstring</returns>
public static async Task<string> GetFileMD5Async(string fullPath)
{
string fallbackValue = String.Empty;
try
{
if (File.Exists(fullPath))
using (MD5 md5 = MD5.Create())
{
using (FileStream stream = File.OpenRead(fullPath))
{
byte[] hashBytes = await md5.ComputeHashAsync(stream);
string hashString = Convert.ToHexStringLower(hashBytes);
return hashString;
}
}
else return fallbackValue;
}
catch
{
return fallbackValue;
}
}
/// <summary>
/// Compute MD5 checksum of a string
/// </summary>
/// <param name="input">Input string for the MD5 hash</param>
/// <returns>MD5 hexstring</returns>
public static string GetStringMD5(string input)
{
// https://stackoverflow.com/a/24031467
byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(input);
byte[] hashBytes = MD5.HashData(inputBytes);
string hashString = Convert.ToHexStringLower(hashBytes);
return hashString;
}
}

236
Tools/Seeder.cs Normal file
View File

@@ -0,0 +1,236 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data;
using Shadow.Entities;
namespace Shadow.Tools;
public class Seeder
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf)
{
db = _db;
guhf = _guhf;
}
public bool Seed()
{
// Will return true if application should be shut down.
// Otherwise returns false.
bool shutdown = false;
Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n");
// Check migration status. If we can't proceed, exit now.
bool migrationSucceeded = EnsureMigrations(db);
if (!migrationSucceeded) return true;
// Check if this is a clean, first run. If so, run the setup wizard.
Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion");
Global? runs = db.Globals.FirstOrDefault(g => g.Key == "runs");
if (lastVersion is null)
{
shutdown = true;
bool setupSuccessfull = false;
Console.WriteLine("This seems to be a fresh install. Running setup wizard for you.\n");
Cli cli = new(db, guhf, []);
setupSuccessfull = cli.SetupWizard();
if (setupSuccessfull)
{
// Cli.SetupWizard() takes care of musicLibraryPath and musicThumbnailPath
lastVersion = new Global() { Key = "lastVersion", Value = ThisAssembly.Git.Commit };
runs = new Global() { Key = "runs", Value = "0" };
Global lastVersionDate = new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate };
Global libraryState = new Global() { Key = "libraryState", Value = "00000000000000000000000000000000" };
db.Globals.AddRange(lastVersion, lastVersionDate, runs, libraryState);
}
else
Console.WriteLine("Setup wizard failed. Please try running it again using \"Shadow setupWizard\".");
db.SaveChanges();
return shutdown;
}
// Check if running a newer (different) version
if (lastVersion.Value != ThisAssembly.Git.Commit)
{
Global lastVersionDate = db.Globals.FirstOrDefault(c => c.Key == "lastVersionDate")
?? new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate };
if (String.Compare(lastVersionDate.Value, ThisAssembly.Git.CommitDate) > 0)
{
// User is running an earlier version of the application.
Console.WriteLine("Downgrade detected! Waiting 30 seconds.\n" +
"Please consider stopping Shadow in order to avoid accidental data loss!\n");
Thread.Sleep(30_000);
}
else
Console.WriteLine("Upgrade detected. Make sure you're using the most recent migrations.\n" +
"If not, apply them automatically or with `dotnet ef database update`.\n");
lastVersion.Value = ThisAssembly.Git.Commit;
lastVersionDate.Value = ThisAssembly.Git.CommitDate;
db.UpdateRange(lastVersion, lastVersionDate);
}
// Check if any user/admin exist. Display appropriate warnings if not.
int userCount = db.Users.Count();
if (userCount == 0)
Console.WriteLine("[Warn]: No user accounts found. Running a server no one can access! Consider creating an account with `Shadow addUser`.\n");
int adminCount = db.Users.Count(u => u.Role == 0); // equivalent to u.IsAdmin()
if (adminCount == 0 && userCount > 0)
Console.WriteLine("[Warn]: No admin accounts exist. Consider creating one with `Shadow addUser`.\n");
// Ensure [Unknown Artist], [Unknown Album] exist
Artist unknownArtist = db.Artists.FirstOrDefault(a => a.Name == "[Unknown Artist]") ?? new Artist()
{
Name = "[Unknown Artist]",
NormalizedName = "[unknown artist]"
};
// Update works both as an Add and Update.
db.Update(unknownArtist);
db.SaveChanges();
Album unknownAlbum = db.Albums.FirstOrDefault(a => a.Name == "[Unknown Album]") ?? new Album()
{
Name = "[Unknown Album]",
Uri = "00000000000000000000000000000000",
Artist = unknownArtist
};
db.Update(unknownAlbum);
// Add [Unknown Album] to [Unknown Artist]
unknownArtist.Albums.Add(unknownAlbum);
db.Update(unknownArtist);
runs ??= new Global() { Key = "runs", Value = "0" };
if (int.TryParse(runs.Value, out int runsInt))
runs.Value = $"{runsInt + 1}";
db.Update(runs);
db.SaveChanges();
return shutdown;
}
public static bool EnsureMigrations(ApplicationDbContext dbContext)
{
// Returns true if migrations have been successfully applied.
// Otherwise returns false.
bool migrationSuccess = false;
List<string> availableMigrations = dbContext.Database.GetMigrations().ToList();
List<string> appliedMigrations = dbContext.Database.GetAppliedMigrations().ToList();
bool hasMissingMigrations = availableMigrations.Count > appliedMigrations.Count;
if (hasMissingMigrations)
{
bool userMigrationConsent = true; // apply migrations by default
Console.WriteLine("\n" +
"========================================\n" +
"[Warn] Database migrations missing!\n" +
"========================================");
if (appliedMigrations.Count == 0)
{
Console.WriteLine( "Empty database detected. Applying migrations is recommended, and required if this is the first run of Shadow.");
// userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true);
}
else
{
Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended.\n" +
"Waiting 15 seconds.");
Thread.Sleep(15_000);
// userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false);
}
// Do we have user permission to perform migration?
if (userMigrationConsent)
{
try
{
dbContext.Database.Migrate();
dbContext.SaveChanges();
}
catch (Exception e)
{
Console.WriteLine("Error! Unable to process migration automatically.\n" +
$"Error message was: {e.Message}\n\n" +
$"Consider ");
}
}
else
{
Console.WriteLine("Exiting...");
return migrationSuccess;
}
// Check if migrations match afterwards
List<string> newlyAppliedMigrations = dbContext.Database.GetAppliedMigrations().ToList();
bool doNewlyAppliedMigrationsMatch = availableMigrations.SequenceEqual(newlyAppliedMigrations);
if (doNewlyAppliedMigrationsMatch)
{
Console.WriteLine("\n" +
"==============================\n" +
"Migration success!\n" +
"==============================\n");
migrationSuccess = true;
}
else
{
// An error ocurred when applying migrations automatically
Console.WriteLine("\n" +
"==================================================\n" +
"[Error] Failed applying migrations automatically!\n" +
"==================================================\n");
Console.WriteLine("The list of newly applied migrations does not match the list of planned migrations.\n" +
"Shadow cannot operate with missing migrations. Please manually revise the database structure and create backups before altering it.\n");
migrationSuccess = false;
}
}
else migrationSuccess = true;
return migrationSuccess;
}
/// <summary>
/// Check if the library needs a full rescan
/// </summary>
/// <returns>True if full rescan is needed</returns>
/// <exception cref="MissingFieldException">Thrown when either last library state, path to music library or cache is unknown</exception>
public async Task<bool> ScanPrefetchAsync()
{
bool scanNecessary = false;
Global? lastLibraryState = await db.Globals.FirstOrDefaultAsync(g => g.Key == "libraryState");
Global? libraryPath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicLibraryPath");
Global? cachePath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicThumbnailPath");
if (libraryPath is null || cachePath is null || lastLibraryState is null)
{
throw new MissingFieldException("[Error] Missing libraryState, musicLibraryPath, musicThumbnailPath. Please rerun the setup wizard with `Shadow setupWizard`.");
}
LibraryWatcher lw = new(libraryPath.Value!, [cachePath.Value!], db);
// Get library contents
List<string> currentLibraryStateList = await lw.GetAllMultimediaAsync();
// Compute their hash
string currentLibraryStateString = string.Join("\n", currentLibraryStateList);
string currentLibraryState = MetadataExtractor.GetStringMD5(currentLibraryStateString);
// Compare against last known library state
if (currentLibraryState != lastLibraryState.Value)
scanNecessary = true;
// The contents changed? Initiate a full rescan, then call LibraryWatcher.
if (scanNecessary)
await lw.PerformFullScanAsync();
// State seems identical? Launch just the LibraryWatcher.
// TODO: lw.Watch()...
return scanNecessary;
}
}

View File

@@ -2,7 +2,9 @@
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",