Compare commits

..

5 Commits

Author SHA1 Message Date
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
14 changed files with 605 additions and 13 deletions

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

@@ -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()
}
}
});
}
}

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;
}

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

@@ -4,6 +4,7 @@ using Microsoft.OpenApi;
using Shadow.Data; using Shadow.Data;
using Shadow.Tools; 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

View File

@@ -43,8 +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="Mapping\" />
</ItemGroup>
</Project> </Project>

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Shadow.Data; using Shadow.Data;
using Shadow.Entities; using Shadow.Entities;
using System.Text; using System.Text;
using System.Xml.Serialization;
namespace Shadow.Tools; namespace Shadow.Tools;
@@ -46,4 +47,55 @@ public class GeneralUseHelpers(ApplicationDbContext? db = null, IConfiguration?
return "{" + resultJson[..^2] + "}"; 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;
}
} }

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data; using Shadow.Data;
using Shadow.Entities; using Shadow.Entities;
using System; using System;
@@ -65,27 +66,79 @@ public class LibraryWatcher(string watchPath, string[] excludedPaths, Applicatio
{ {
Console.WriteLine("Performing full library scan..."); Console.WriteLine("Performing full library scan...");
List<string> multimedia = await GetAllMultimediaAsync(); // Current library state as present in database
foreach (string filepath in multimedia) 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); Console.WriteLine(filepath);
Dictionary<string, string> fileInfo = await MetadataExtractor.ExtractAsync(filepath); Dictionary<string, string> fileInfo = await MetadataExtractor.ExtractAsync(filepath);
// Pretend we are doing parsing here... // Pretend we are doing parsing here...
Console.WriteLine(GeneralUseHelpers.DictAsJson(fileInfo)); Console.WriteLine(GeneralUseHelpers.DictAsJson(fileInfo));
MediaParser.CreateSong(db, 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 {multimedia.Count} files."); 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 // Update state inside of DB
string currentLibraryState = MetadataExtractor.GetStringMD5(string.Join("\n", multimedia)); string updatedLibraryState = MetadataExtractor.GetStringMD5(string.Join("\n", newMultimediaPathNames));
Global lastLibraryState = db.Globals.FirstOrDefault(g => g.Key == "libraryState") Global lastLibraryState = db.Globals.FirstOrDefault(g => g.Key == "libraryState")
?? new() { Key = "libraryState"}; ?? new() { Key = "libraryState"};
lastLibraryState.Value = currentLibraryState; lastLibraryState.Value = updatedLibraryState;
db.Update(lastLibraryState); db.Update(lastLibraryState);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return multimedia; return newMultimediaPathNames;
} }
} }

View File

@@ -75,6 +75,8 @@ public static class MediaParser
"ID3v2_3:Album", // Generic mp3/wav ID3 v2.3.0 "ID3v2_3:Album", // Generic mp3/wav ID3 v2.3.0
]) ?? "[Unknown Album]"; // again, weak line of defense ]) ?? "[Unknown Album]"; // again, weak line of defense
// TODO: Try and find genres
// Try to find relevant artists and albums // Try to find relevant artists and albums
Artist artist = db.Artists Artist artist = db.Artists
.FirstOrDefault(a => a.NormalizedName == artistName.ToLower()) .FirstOrDefault(a => a.NormalizedName == artistName.ToLower())
@@ -108,6 +110,7 @@ public static class MediaParser
// Is Update() safe here? // Is Update() safe here?
db.Artists.Update(artist); db.Artists.Update(artist);
db.Albums.Update(album); db.Albums.Update(album);
db.SaveChanges();
db.Songs.Update(song); db.Songs.Update(song);
artist.Albums.Add(album); artist.Albums.Add(album);
artist.Songs.Add(song); artist.Songs.Add(song);
@@ -115,7 +118,7 @@ public static class MediaParser
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine("[Error: MediaParser] Failed to extract metadata from {filepath}:\n" + Console.WriteLine($"[Error: MediaParser] Failed to extract metadata from {filepath}:\n" +
$"{e}"); $"{e}");
} }