mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Release 1.0.0 - Production ready
- Fixed AdminController export/import .env endpoints (moved from ConfigUpdateRequest class) - Added ArtistId and AlbumId to integration test fixtures - All 225 tests passing - Version set to 1.0.0 (semantic versioning) - MusicBrainz service ready for future ISRC-based matching (1.1.0) - Import/export handles full .env configuration with timestamped backups
This commit is contained in:
@@ -0,0 +1,334 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests to verify Jellyfin response structure matches real API responses.
|
||||||
|
/// </summary>
|
||||||
|
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<string[]>(result["Genres"]);
|
||||||
|
Assert.NotNull(result["GenreItems"]);
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(result["GenreItems"]);
|
||||||
|
|
||||||
|
// Assert - UserData
|
||||||
|
Assert.NotNull(result["UserData"]);
|
||||||
|
var userData = result["UserData"] as Dictionary<string, object>;
|
||||||
|
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<string, object?>;
|
||||||
|
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<string, object?>;
|
||||||
|
var mediaStreams = mediaSource!["MediaStreams"] as object[];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(mediaStreams);
|
||||||
|
Assert.Single(mediaStreams);
|
||||||
|
|
||||||
|
var audioStream = mediaStreams[0] as Dictionary<string, object?>;
|
||||||
|
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<string[]>(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<string[]>(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<string, object>;
|
||||||
|
Assert.NotNull(songUserData);
|
||||||
|
Assert.Contains("ItemId", songUserData.Keys);
|
||||||
|
Assert.Equal("song-id", songUserData["ItemId"]);
|
||||||
|
|
||||||
|
var albumUserData = albumResult["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(albumUserData);
|
||||||
|
Assert.Contains("ItemId", albumUserData.Keys);
|
||||||
|
Assert.Equal("album-id", albumUserData["ItemId"]);
|
||||||
|
|
||||||
|
var artistUserData = artistResult["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(artistUserData);
|
||||||
|
Assert.Contains("ItemId", artistUserData.Keys);
|
||||||
|
Assert.Equal("artist-id", artistUserData["ItemId"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class AdminController : ControllerBase
|
|||||||
private readonly DeezerSettings _deezerSettings;
|
private readonly DeezerSettings _deezerSettings;
|
||||||
private readonly QobuzSettings _qobuzSettings;
|
private readonly QobuzSettings _qobuzSettings;
|
||||||
private readonly SquidWTFSettings _squidWtfSettings;
|
private readonly SquidWTFSettings _squidWtfSettings;
|
||||||
|
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||||
private readonly SpotifyApiClient _spotifyClient;
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||||
@@ -47,6 +48,7 @@ public class AdminController : ControllerBase
|
|||||||
IOptions<DeezerSettings> deezerSettings,
|
IOptions<DeezerSettings> deezerSettings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||||
|
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||||
SpotifyApiClient spotifyClient,
|
SpotifyApiClient spotifyClient,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -62,6 +64,7 @@ public class AdminController : ControllerBase
|
|||||||
_deezerSettings = deezerSettings.Value;
|
_deezerSettings = deezerSettings.Value;
|
||||||
_qobuzSettings = qobuzSettings.Value;
|
_qobuzSettings = qobuzSettings.Value;
|
||||||
_squidWtfSettings = squidWtfSettings.Value;
|
_squidWtfSettings = squidWtfSettings.Value;
|
||||||
|
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||||
_spotifyClient = spotifyClient;
|
_spotifyClient = spotifyClient;
|
||||||
_playlistFetcher = playlistFetcher;
|
_playlistFetcher = playlistFetcher;
|
||||||
_matchingService = matchingService;
|
_matchingService = matchingService;
|
||||||
@@ -721,6 +724,14 @@ public class AdminController : ControllerBase
|
|||||||
squidWtf = new
|
squidWtf = new
|
||||||
{
|
{
|
||||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
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
|
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export .env file for backup/transfer
|
||||||
|
/// </summary>
|
||||||
|
[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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import .env file from upload
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("import-env")]
|
||||||
|
public async Task<IActionResult> 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
|
public class ConfigUpdateRequest
|
||||||
|
|||||||
@@ -734,6 +734,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>MusicBrainz Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Enabled</span>
|
||||||
|
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Username</span>
|
||||||
|
<span class="value" id="config-musicbrainz-username">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Password</span>
|
||||||
|
<span class="value" id="config-musicbrainz-password">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Qobuz Settings</h2>
|
<h2>Qobuz Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -792,6 +813,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Configuration Backup</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Export your .env configuration for backup or import a previously saved configuration.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button onclick="exportEnv()">📥 Export .env</button>
|
||||||
|
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
|
||||||
|
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||||
<h2 style="color: var(--error);">Danger Zone</h2>
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
@@ -1223,6 +1256,11 @@
|
|||||||
// SquidWTF settings
|
// SquidWTF settings
|
||||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
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
|
// Qobuz settings
|
||||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
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() {
|
async function restartContainer() {
|
||||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user