diff --git a/allstarr.Tests/JellyfinResponseStructureTests.cs b/allstarr.Tests/JellyfinResponseStructureTests.cs index e69de29..a60b14a 100644 --- a/allstarr.Tests/JellyfinResponseStructureTests.cs +++ b/allstarr.Tests/JellyfinResponseStructureTests.cs @@ -0,0 +1,334 @@ +using System.Text.Json; +using Xunit; +using allstarr.Models.Domain; +using allstarr.Services.Jellyfin; + +namespace allstarr.Tests; + +/// +/// Integration tests to verify Jellyfin response structure matches real API responses. +/// +public class JellyfinResponseStructureTests +{ + private readonly JellyfinResponseBuilder _builder; + + public JellyfinResponseStructureTests() + { + _builder = new JellyfinResponseBuilder(); + } + + [Fact] + public void Track_Response_Should_Have_All_Required_Fields() + { + // Arrange + var song = new Song + { + Id = "test-id", + Title = "Test Song", + Artist = "Test Artist", + ArtistId = "artist-id", + Album = "Test Album", + AlbumId = "album-id", + Duration = 180, + Year = 2024, + Track = 1, + Genre = "Pop", + IsLocal = false, + ExternalProvider = "Deezer", + ExternalId = "123456" + }; + + // Act + var result = _builder.ConvertSongToJellyfinItem(song); + + // Assert - Required top-level fields + Assert.NotNull(result["Name"]); + Assert.NotNull(result["ServerId"]); + Assert.NotNull(result["Id"]); + Assert.NotNull(result["Type"]); + Assert.Equal("Audio", result["Type"]); + Assert.NotNull(result["MediaType"]); + Assert.Equal("Audio", result["MediaType"]); + + // Assert - Metadata fields + Assert.NotNull(result["Container"]); + Assert.Equal("flac", result["Container"]); + Assert.NotNull(result["HasLyrics"]); + Assert.False((bool)result["HasLyrics"]!); + + // Assert - Genres (must be array, never null) + Assert.NotNull(result["Genres"]); + Assert.IsType(result["Genres"]); + Assert.NotNull(result["GenreItems"]); + Assert.IsAssignableFrom(result["GenreItems"]); + + // Assert - UserData + Assert.NotNull(result["UserData"]); + var userData = result["UserData"] as Dictionary; + Assert.NotNull(userData); + Assert.Contains("ItemId", userData.Keys); + Assert.Contains("Key", userData.Keys); + + // Assert - Image fields + Assert.NotNull(result["ImageTags"]); + Assert.NotNull(result["BackdropImageTags"]); + Assert.NotNull(result["ImageBlurHashes"]); + + // Assert - Location + Assert.NotNull(result["LocationType"]); + Assert.Equal("FileSystem", result["LocationType"]); + + // Assert - Parent references + Assert.NotNull(result["ParentLogoItemId"]); + Assert.NotNull(result["ParentBackdropItemId"]); + Assert.NotNull(result["ParentBackdropImageTags"]); + } + + [Fact] + public void Track_MediaSources_Should_Have_Complete_Structure() + { + // Arrange + var song = new Song + { + Id = "test-id", + Title = "Test Song", + Artist = "Test Artist", + Album = "Test Album", + Duration = 180, + IsLocal = false, + ExternalProvider = "Deezer", + ExternalId = "123456" + }; + + // Act + var result = _builder.ConvertSongToJellyfinItem(song); + + // Assert - MediaSources exists + Assert.NotNull(result["MediaSources"]); + var mediaSources = result["MediaSources"] as object[]; + Assert.NotNull(mediaSources); + Assert.Single(mediaSources); + + var mediaSource = mediaSources[0] as Dictionary; + Assert.NotNull(mediaSource); + + // Assert - Required MediaSource fields + Assert.Contains("Protocol", mediaSource.Keys); + Assert.Contains("Id", mediaSource.Keys); + Assert.Contains("Path", mediaSource.Keys); + Assert.Contains("Type", mediaSource.Keys); + Assert.Contains("Container", mediaSource.Keys); + Assert.Contains("Bitrate", mediaSource.Keys); + Assert.Contains("ETag", mediaSource.Keys); + Assert.Contains("RunTimeTicks", mediaSource.Keys); + + // Assert - Boolean flags + Assert.Contains("IsRemote", mediaSource.Keys); + Assert.Contains("IsInfiniteStream", mediaSource.Keys); + Assert.Contains("RequiresOpening", mediaSource.Keys); + Assert.Contains("RequiresClosing", mediaSource.Keys); + Assert.Contains("RequiresLooping", mediaSource.Keys); + Assert.Contains("SupportsProbing", mediaSource.Keys); + Assert.Contains("SupportsTranscoding", mediaSource.Keys); + Assert.Contains("SupportsDirectStream", mediaSource.Keys); + Assert.Contains("SupportsDirectPlay", mediaSource.Keys); + Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys); + Assert.Contains("IgnoreDts", mediaSource.Keys); + Assert.Contains("IgnoreIndex", mediaSource.Keys); + Assert.Contains("GenPtsInput", mediaSource.Keys); + Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys); + Assert.Contains("HasSegments", mediaSource.Keys); + + // Assert - Arrays (must not be null) + Assert.Contains("MediaStreams", mediaSource.Keys); + Assert.NotNull(mediaSource["MediaStreams"]); + Assert.Contains("MediaAttachments", mediaSource.Keys); + Assert.NotNull(mediaSource["MediaAttachments"]); + Assert.Contains("Formats", mediaSource.Keys); + Assert.NotNull(mediaSource["Formats"]); + Assert.Contains("RequiredHttpHeaders", mediaSource.Keys); + Assert.NotNull(mediaSource["RequiredHttpHeaders"]); + + // Assert - Other fields + Assert.Contains("TranscodingSubProtocol", mediaSource.Keys); + Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys); + } + + [Fact] + public void Track_MediaStreams_Should_Have_Complete_Audio_Stream() + { + // Arrange + var song = new Song + { + Id = "test-id", + Title = "Test Song", + Artist = "Test Artist", + IsLocal = false, + ExternalProvider = "Deezer" + }; + + // Act + var result = _builder.ConvertSongToJellyfinItem(song); + var mediaSources = result["MediaSources"] as object[]; + var mediaSource = mediaSources![0] as Dictionary; + var mediaStreams = mediaSource!["MediaStreams"] as object[]; + + // Assert + Assert.NotNull(mediaStreams); + Assert.Single(mediaStreams); + + var audioStream = mediaStreams[0] as Dictionary; + Assert.NotNull(audioStream); + + // Assert - Required audio stream fields + Assert.Contains("Codec", audioStream.Keys); + Assert.Equal("flac", audioStream["Codec"]); + Assert.Contains("Type", audioStream.Keys); + Assert.Equal("Audio", audioStream["Type"]); + Assert.Contains("BitRate", audioStream.Keys); + Assert.Contains("Channels", audioStream.Keys); + Assert.Contains("SampleRate", audioStream.Keys); + Assert.Contains("BitDepth", audioStream.Keys); + Assert.Contains("ChannelLayout", audioStream.Keys); + Assert.Contains("TimeBase", audioStream.Keys); + Assert.Contains("DisplayTitle", audioStream.Keys); + + // Assert - Video-related fields (required even for audio) + Assert.Contains("VideoRange", audioStream.Keys); + Assert.Contains("VideoRangeType", audioStream.Keys); + Assert.Contains("AudioSpatialFormat", audioStream.Keys); + + // Assert - Localization + Assert.Contains("LocalizedDefault", audioStream.Keys); + Assert.Contains("LocalizedExternal", audioStream.Keys); + + // Assert - Boolean flags + Assert.Contains("IsInterlaced", audioStream.Keys); + Assert.Contains("IsAVC", audioStream.Keys); + Assert.Contains("IsDefault", audioStream.Keys); + Assert.Contains("IsForced", audioStream.Keys); + Assert.Contains("IsHearingImpaired", audioStream.Keys); + Assert.Contains("IsExternal", audioStream.Keys); + Assert.Contains("IsTextSubtitleStream", audioStream.Keys); + Assert.Contains("SupportsExternalStream", audioStream.Keys); + + // Assert - Index and Level + Assert.Contains("Index", audioStream.Keys); + Assert.Contains("Level", audioStream.Keys); + } + + [Fact] + public void Album_Response_Should_Have_All_Required_Fields() + { + // Arrange + var album = new Album + { + Id = "album-id", + Title = "Test Album", + Artist = "Test Artist", + Year = 2024, + Genre = "Rock", + IsLocal = false, + ExternalProvider = "Deezer" + }; + + // Act + var result = _builder.ConvertAlbumToJellyfinItem(album); + + // Assert + Assert.NotNull(result["Name"]); + Assert.NotNull(result["ServerId"]); + Assert.NotNull(result["Id"]); + Assert.NotNull(result["Type"]); + Assert.Equal("MusicAlbum", result["Type"]); + Assert.True((bool)result["IsFolder"]!); + Assert.NotNull(result["MediaType"]); + Assert.Equal("Unknown", result["MediaType"]); + + // Assert - Genres + Assert.NotNull(result["Genres"]); + Assert.IsType(result["Genres"]); + Assert.NotNull(result["GenreItems"]); + + // Assert - Artists + Assert.NotNull(result["Artists"]); + Assert.NotNull(result["ArtistItems"]); + Assert.NotNull(result["AlbumArtist"]); + Assert.NotNull(result["AlbumArtists"]); + + // Assert - Parent references + Assert.NotNull(result["ParentLogoItemId"]); + Assert.NotNull(result["ParentBackdropItemId"]); + Assert.NotNull(result["ParentLogoImageTag"]); + } + + [Fact] + public void Artist_Response_Should_Have_All_Required_Fields() + { + // Arrange + var artist = new Artist + { + Id = "artist-id", + Name = "Test Artist", + AlbumCount = 5, + IsLocal = false, + ExternalProvider = "Deezer" + }; + + // Act + var result = _builder.ConvertArtistToJellyfinItem(artist); + + // Assert + Assert.NotNull(result["Name"]); + Assert.NotNull(result["ServerId"]); + Assert.NotNull(result["Id"]); + Assert.NotNull(result["Type"]); + Assert.Equal("MusicArtist", result["Type"]); + Assert.True((bool)result["IsFolder"]!); + Assert.NotNull(result["MediaType"]); + Assert.Equal("Unknown", result["MediaType"]); + + // Assert - Genres (empty array for artists) + Assert.NotNull(result["Genres"]); + Assert.IsType(result["Genres"]); + Assert.NotNull(result["GenreItems"]); + + // Assert - Album count + Assert.NotNull(result["AlbumCount"]); + Assert.Equal(5, result["AlbumCount"]); + + // Assert - RunTimeTicks + Assert.NotNull(result["RunTimeTicks"]); + Assert.Equal(0, result["RunTimeTicks"]); + } + + [Fact] + public void All_Entities_Should_Have_UserData_With_ItemId() + { + // Arrange + var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" }; + var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" }; + var artist = new Artist { Id = "artist-id", Name = "Test" }; + + // Act + var songResult = _builder.ConvertSongToJellyfinItem(song); + var albumResult = _builder.ConvertAlbumToJellyfinItem(album); + var artistResult = _builder.ConvertArtistToJellyfinItem(artist); + + // Assert + var songUserData = songResult["UserData"] as Dictionary; + Assert.NotNull(songUserData); + Assert.Contains("ItemId", songUserData.Keys); + Assert.Equal("song-id", songUserData["ItemId"]); + + var albumUserData = albumResult["UserData"] as Dictionary; + Assert.NotNull(albumUserData); + Assert.Contains("ItemId", albumUserData.Keys); + Assert.Equal("album-id", albumUserData["ItemId"]); + + var artistUserData = artistResult["UserData"] as Dictionary; + Assert.NotNull(artistUserData); + Assert.Contains("ItemId", artistUserData.Keys); + Assert.Equal("artist-id", artistUserData["ItemId"]); + } +} diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index b7363b6..26609b2 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -28,6 +28,7 @@ public class AdminController : ControllerBase private readonly DeezerSettings _deezerSettings; private readonly QobuzSettings _qobuzSettings; private readonly SquidWTFSettings _squidWtfSettings; + private readonly MusicBrainzSettings _musicBrainzSettings; private readonly SpotifyApiClient _spotifyClient; private readonly SpotifyPlaylistFetcher _playlistFetcher; private readonly SpotifyTrackMatchingService? _matchingService; @@ -47,6 +48,7 @@ public class AdminController : ControllerBase IOptions deezerSettings, IOptions qobuzSettings, IOptions squidWtfSettings, + IOptions musicBrainzSettings, SpotifyApiClient spotifyClient, SpotifyPlaylistFetcher playlistFetcher, RedisCacheService cache, @@ -62,6 +64,7 @@ public class AdminController : ControllerBase _deezerSettings = deezerSettings.Value; _qobuzSettings = qobuzSettings.Value; _squidWtfSettings = squidWtfSettings.Value; + _musicBrainzSettings = musicBrainzSettings.Value; _spotifyClient = spotifyClient; _playlistFetcher = playlistFetcher; _matchingService = matchingService; @@ -721,6 +724,14 @@ public class AdminController : ControllerBase squidWtf = new { quality = _squidWtfSettings.Quality ?? "LOSSLESS" + }, + musicBrainz = new + { + enabled = _musicBrainzSettings.Enabled, + username = _musicBrainzSettings.Username ?? "(not set)", + password = MaskValue(_musicBrainzSettings.Password), + baseUrl = _musicBrainzSettings.BaseUrl, + rateLimitMs = _musicBrainzSettings.RateLimitMs } }); } @@ -1551,6 +1562,85 @@ public class AdminController : ControllerBase // Only allow alphanumeric, underscore, and must start with letter/underscore return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase); } + + /// + /// Export .env file for backup/transfer + /// + [HttpGet("export-env")] + public IActionResult ExportEnv() + { + try + { + if (!System.IO.File.Exists(_envFilePath)) + { + return NotFound(new { error = ".env file not found" }); + } + + var envContent = System.IO.File.ReadAllText(_envFilePath); + var bytes = System.Text.Encoding.UTF8.GetBytes(envContent); + + return File(bytes, "text/plain", ".env"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export .env file"); + return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message }); + } + } + + /// + /// Import .env file from upload + /// + [HttpPost("import-env")] + public async Task ImportEnv([FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest(new { error = "No file provided" }); + } + + if (!file.FileName.EndsWith(".env")) + { + return BadRequest(new { error = "File must be a .env file" }); + } + + try + { + // Read uploaded file + using var reader = new StreamReader(file.OpenReadStream()); + var content = await reader.ReadToEndAsync(); + + // Validate it's a valid .env file (basic check) + if (string.IsNullOrWhiteSpace(content)) + { + return BadRequest(new { error = ".env file is empty" }); + } + + // Backup existing .env + if (System.IO.File.Exists(_envFilePath)) + { + var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}"; + System.IO.File.Copy(_envFilePath, backupPath, true); + _logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath); + } + + // Write new .env file + await System.IO.File.WriteAllTextAsync(_envFilePath, content); + + _logger.LogInformation(".env file imported successfully"); + + return Ok(new + { + success = true, + message = ".env file imported successfully. Restart the application for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import .env file"); + return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message }); + } + } } public class ConfigUpdateRequest diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 8161b7a..049b0b9 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -734,6 +734,27 @@ +
+

MusicBrainz Settings

+
+
+ Enabled + - + +
+
+ Username + - + +
+
+ Password + - + +
+
+
+

Qobuz Settings

@@ -792,6 +813,18 @@
+
+

Configuration Backup

+

+ Export your .env configuration for backup or import a previously saved configuration. +

+
+ + + +
+
+

Danger Zone

@@ -1223,6 +1256,11 @@ // SquidWTF settings document.getElementById('config-squid-quality').textContent = data.squidWtf.quality; + // MusicBrainz settings + document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No'; + document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)'; + document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)'; + // Qobuz settings document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)'; document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC'; @@ -1487,6 +1525,61 @@ } } + async function exportEnv() { + try { + const res = await fetch('/api/admin/export-env'); + if (!res.ok) { + throw new Error('Export failed'); + } + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast('.env file exported successfully', 'success'); + } catch (error) { + showToast('Failed to export .env file', 'error'); + } + } + + async function importEnv(event) { + const file = event.target.files[0]; + if (!file) return; + + if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) { + event.target.value = ''; + return; + } + + try { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/admin/import-env', { + method: 'POST', + body: formData + }); + + const data = await res.json(); + + if (res.ok) { + showToast(data.message, 'success'); + } else { + showToast(data.error || 'Failed to import .env file', 'error'); + } + } catch (error) { + showToast('Failed to import .env file', 'error'); + } + + event.target.value = ''; + } + async function restartContainer() { if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) { return;