diff --git a/MusifyBackend/.agents/skills/csharp-async/SKILL.md b/MusifyBackend/.agents/skills/csharp-async/SKILL.md new file mode 100644 index 0000000..4dbe78b --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-async/SKILL.md @@ -0,0 +1,49 @@ +--- +name: csharp-async +description: 'Get best practices for C# async programming' +--- + +# C# Async Programming Best Practices + +Your goal is to help me follow best practices for asynchronous programming in C#. + +## Naming Conventions + +- Use the 'Async' suffix for all async methods +- Match method names with their synchronous counterparts when applicable (e.g., `GetDataAsync()` for `GetData()`) + +## Return Types + +- Return `Task` when the method returns a value +- Return `Task` when the method doesn't return a value +- Consider `ValueTask` for high-performance scenarios to reduce allocations +- Avoid returning `void` for async methods except for event handlers + +## Exception Handling + +- Use try/catch blocks around await expressions +- Avoid swallowing exceptions in async methods +- Use `ConfigureAwait(false)` when appropriate to prevent deadlocks in library code +- Propagate exceptions with `Task.FromException()` instead of throwing in async Task returning methods + +## Performance + +- Use `Task.WhenAll()` for parallel execution of multiple tasks +- Use `Task.WhenAny()` for implementing timeouts or taking the first completed task +- Avoid unnecessary async/await when simply passing through task results +- Consider cancellation tokens for long-running operations + +## Common Pitfalls + +- Never use `.Wait()`, `.Result`, or `.GetAwaiter().GetResult()` in async code +- Avoid mixing blocking and async code +- Don't create async void methods (except for event handlers) +- Always await Task-returning methods + +## Implementation Patterns + +- Implement the async command pattern for long-running operations +- Use async streams (IAsyncEnumerable) for processing sequences asynchronously +- Consider the task-based asynchronous pattern (TAP) for public APIs + +When reviewing my C# code, identify these issues and suggest improvements that follow these best practices. diff --git a/MusifyBackend/.agents/skills/csharp-docs/SKILL.md b/MusifyBackend/.agents/skills/csharp-docs/SKILL.md new file mode 100644 index 0000000..6c67306 --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-docs/SKILL.md @@ -0,0 +1,62 @@ +--- +name: csharp-docs +description: 'Ensure that C# types are documented with XML comments and follow best practices for documentation.' +--- + +# C# Documentation Best Practices + +- Public members should be documented with XML comments. +- It is encouraged to document internal members as well, especially if they are complex or not self-explanatory. + +## Guidance for all APIs + +- Use `` to provide a brief, one sentence, description of what the type or member does. Start the summary with a present-tense, third-person verb. +- Use `` for additional information, which can include implementation details, usage notes, or any other relevant context. +- Use `` for language-specific keywords like `null`, `true`, `false`, `int`, `bool`, etc. +- Use `` for inline code snippets. +- Use `` for usage examples on how to use the member. + - Use `` for code blocks. `` tags should be placed within an `` tag. Add the language of the code example using the `language` attribute, for example, ``. +- Use `` to reference other types or members inline (in a sentence). +- Use `` for standalone (not in a sentence) references to other types or members in the "See also" section of the online docs. +- Use `` to inherit documentation from base classes or interfaces. + - Unless there is major behavior change, in which case you should document the differences. + +## Methods + +- Use `` to describe method parameters. + - The description should be a noun phrase that doesn't specify the data type. + - Begin with an introductory article. + - If the parameter is a flag enum, start the description with "A bitwise combination of the enumeration values that specifies...". + - If the parameter is a non-flag enum, start the description with "One of the enumeration values that specifies...". + - If the parameter is a Boolean, the wording should be of the form "`` to ...; otherwise, ``.". + - If the parameter is an "out" parameter, the wording should be of the form "When this method returns, contains .... This parameter is treated as uninitialized.". +- Use `` to reference parameter names in documentation. +- Use `` to describe type parameters in generic types or methods. +- Use `` to reference type parameters in documentation. +- Use `` to describe what the method returns. + - The description should be a noun phrase that doesn't specify the data type. + - Begin with an introductory article. + - If the return type is Boolean, the wording should be of the form "`` if ...; otherwise, ``.". + +## Constructors + +- The summary wording should be "Initializes a new instance of the class [or struct].". + +## Properties + +- The `` should start with: + - "Gets or sets..." for a read-write property. + - "Gets..." for a read-only property. + - "Gets [or sets] a value that indicates whether..." for properties that return a Boolean value. +- Use `` to describe the value of the property. + - The description should be a noun phrase that doesn't specify the data type. + - If the property has a default value, add it in a separate sentence, for example, "The default is ``". + - If the value type is Boolean, the wording should be of the form "`` if ...; otherwise, ``. The default is ...". + +## Exceptions + +- Use `` to document exceptions thrown by constructors, properties, indexers, methods, operators, and events. +- Document all exceptions thrown directly by the member. +- For exceptions thrown by nested members, document only the exceptions users are most likely to encounter. +- The description of the exception describes the condition under which it's thrown. + - Omit "Thrown if ..." or "If ..." at the beginning of the sentence. Just state the condition directly, for example "An error occurred when accessing a Message Queuing API." diff --git a/MusifyBackend/.agents/skills/csharp-mstest/SKILL.md b/MusifyBackend/.agents/skills/csharp-mstest/SKILL.md new file mode 100644 index 0000000..e68bc31 --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-mstest/SKILL.md @@ -0,0 +1,478 @@ +--- +name: csharp-mstest +description: 'Get best practices for MSTest 3.x/4.x unit testing, including modern assertion APIs and data-driven tests' +--- + +# MSTest Best Practices (MSTest 3.x/4.x) + +Your goal is to help me write effective unit tests with modern MSTest, using current APIs and best practices. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference MSTest 3.x+ NuGet packages (includes analyzers) +- Consider using MSTest.Sdk for simplified project setup +- Run tests with `dotnet test` + +## Test Class Structure + +- Use `[TestClass]` attribute for test classes +- **Seal test classes by default** for performance and design clarity +- Use `[TestMethod]` for test methods (prefer over `[DataTestMethod]`) +- Follow Arrange-Act-Assert (AAA) pattern +- Name tests using pattern `MethodName_Scenario_ExpectedBehavior` + +```csharp +[TestClass] +public sealed class CalculatorTests +{ + [TestMethod] + public void Add_TwoPositiveNumbers_ReturnsSum() + { + // Arrange + var calculator = new Calculator(); + + // Act + var result = calculator.Add(2, 3); + + // Assert + Assert.AreEqual(5, result); + } +} +``` + +## Test Lifecycle + +- **Prefer constructors over `[TestInitialize]`** - enables `readonly` fields and follows standard C# patterns +- Use `[TestCleanup]` for cleanup that must run even if test fails +- Combine constructor with async `[TestInitialize]` when async setup is needed + +```csharp +[TestClass] +public sealed class ServiceTests +{ + private readonly MyService _service; // readonly enabled by constructor + + public ServiceTests() + { + _service = new MyService(); + } + + [TestInitialize] + public async Task InitAsync() + { + // Use for async initialization only + await _service.WarmupAsync(); + } + + [TestCleanup] + public void Cleanup() => _service.Reset(); +} +``` + +### Execution Order + +1. **Assembly Initialization** - `[AssemblyInitialize]` (once per test assembly) +2. **Class Initialization** - `[ClassInitialize]` (once per test class) +3. **Test Initialization** (for every test method): + 1. Constructor + 2. Set `TestContext` property + 3. `[TestInitialize]` +4. **Test Execution** - test method runs +5. **Test Cleanup** (for every test method): + 1. `[TestCleanup]` + 2. `DisposeAsync` (if implemented) + 3. `Dispose` (if implemented) +6. **Class Cleanup** - `[ClassCleanup]` (once per test class) +7. **Assembly Cleanup** - `[AssemblyCleanup]` (once per test assembly) + +## Modern Assertion APIs + +MSTest provides three assertion classes: `Assert`, `StringAssert`, and `CollectionAssert`. + +### Assert Class - Core Assertions + +```csharp +// Equality +Assert.AreEqual(expected, actual); +Assert.AreNotEqual(notExpected, actual); +Assert.AreSame(expectedObject, actualObject); // Reference equality +Assert.AreNotSame(notExpectedObject, actualObject); + +// Null checks +Assert.IsNull(value); +Assert.IsNotNull(value); + +// Boolean +Assert.IsTrue(condition); +Assert.IsFalse(condition); + +// Fail/Inconclusive +Assert.Fail("Test failed due to..."); +Assert.Inconclusive("Test cannot be completed because..."); +``` + +### Exception Testing (Prefer over `[ExpectedException]`) + +```csharp +// Assert.Throws - matches TException or derived types +var ex = Assert.Throws(() => Method(null)); +Assert.AreEqual("Value cannot be null.", ex.Message); + +// Assert.ThrowsExactly - matches exact type only +var ex = Assert.ThrowsExactly(() => Method()); + +// Async versions +var ex = await Assert.ThrowsAsync(async () => await client.GetAsync(url)); +var ex = await Assert.ThrowsExactlyAsync(async () => await Method()); +``` + +### Collection Assertions (Assert class) + +```csharp +Assert.Contains(expectedItem, collection); +Assert.DoesNotContain(unexpectedItem, collection); +Assert.ContainsSingle(collection); // exactly one element +Assert.HasCount(5, collection); +Assert.IsEmpty(collection); +Assert.IsNotEmpty(collection); +``` + +### String Assertions (Assert class) + +```csharp +Assert.Contains("expected", actualString); +Assert.StartsWith("prefix", actualString); +Assert.EndsWith("suffix", actualString); +Assert.DoesNotStartWith("prefix", actualString); +Assert.DoesNotEndWith("suffix", actualString); +Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber); +Assert.DoesNotMatchRegex(@"\d+", textOnly); +``` + +### Comparison Assertions + +```csharp +Assert.IsGreaterThan(lowerBound, actual); +Assert.IsGreaterThanOrEqualTo(lowerBound, actual); +Assert.IsLessThan(upperBound, actual); +Assert.IsLessThanOrEqualTo(upperBound, actual); +Assert.IsInRange(actual, low, high); +Assert.IsPositive(number); +Assert.IsNegative(number); +``` + +### Type Assertions + +```csharp +// MSTest 3.x - uses out parameter +Assert.IsInstanceOfType(obj, out var typed); +typed.DoSomething(); + +// MSTest 4.x - returns typed result directly +var typed = Assert.IsInstanceOfType(obj); +typed.DoSomething(); + +Assert.IsNotInstanceOfType(obj); +``` + +### Assert.That (MSTest 4.0+) + +```csharp +Assert.That(result.Count > 0); // Auto-captures expression in failure message +``` + +### StringAssert Class + +> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains("expected", actual)` over `StringAssert.Contains(actual, "expected")`). + +```csharp +StringAssert.Contains(actualString, "expected"); +StringAssert.StartsWith(actualString, "prefix"); +StringAssert.EndsWith(actualString, "suffix"); +StringAssert.Matches(actualString, new Regex(@"\d{3}-\d{4}")); +StringAssert.DoesNotMatch(actualString, new Regex(@"\d+")); +``` + +### CollectionAssert Class + +> **Note:** Prefer `Assert` class equivalents when available (e.g., `Assert.Contains`). + +```csharp +// Containment +CollectionAssert.Contains(collection, expectedItem); +CollectionAssert.DoesNotContain(collection, unexpectedItem); + +// Equality (same elements, same order) +CollectionAssert.AreEqual(expectedCollection, actualCollection); +CollectionAssert.AreNotEqual(unexpectedCollection, actualCollection); + +// Equivalence (same elements, any order) +CollectionAssert.AreEquivalent(expectedCollection, actualCollection); +CollectionAssert.AreNotEquivalent(unexpectedCollection, actualCollection); + +// Subset checks +CollectionAssert.IsSubsetOf(subset, superset); +CollectionAssert.IsNotSubsetOf(notSubset, collection); + +// Element validation +CollectionAssert.AllItemsAreInstancesOfType(collection, typeof(MyClass)); +CollectionAssert.AllItemsAreNotNull(collection); +CollectionAssert.AllItemsAreUnique(collection); +``` + +## Data-Driven Tests + +### DataRow + +```csharp +[TestMethod] +[DataRow(1, 2, 3)] +[DataRow(0, 0, 0, DisplayName = "Zeros")] +[DataRow(-1, 1, 0, IgnoreMessage = "Known issue #123")] // MSTest 3.8+ +public void Add_ReturnsSum(int a, int b, int expected) +{ + Assert.AreEqual(expected, Calculator.Add(a, b)); +} +``` + +### DynamicData + +The data source can return any of the following types: + +- `IEnumerable<(T1, T2, ...)>` (ValueTuple) - **preferred**, provides type safety (MSTest 3.7+) +- `IEnumerable>` - provides type safety +- `IEnumerable` - provides type safety plus control over test metadata (display name, categories) +- `IEnumerable` - **least preferred**, no type safety + +> **Note:** When creating new test data methods, prefer `ValueTuple` or `TestDataRow` over `IEnumerable`. The `object[]` approach provides no compile-time type checking and can lead to runtime errors from type mismatches. + +```csharp +[TestMethod] +[DynamicData(nameof(TestData))] +public void DynamicTest(int a, int b, int expected) +{ + Assert.AreEqual(expected, Calculator.Add(a, b)); +} + +// ValueTuple - preferred (MSTest 3.7+) +public static IEnumerable<(int a, int b, int expected)> TestData => +[ + (1, 2, 3), + (0, 0, 0), +]; + +// TestDataRow - when you need custom display names or metadata +public static IEnumerable> TestDataWithMetadata => +[ + new((1, 2, 3)) { DisplayName = "Positive numbers" }, + new((0, 0, 0)) { DisplayName = "Zeros" }, + new((-1, 1, 0)) { DisplayName = "Mixed signs", IgnoreMessage = "Known issue #123" }, +]; + +// IEnumerable - avoid for new code (no type safety) +public static IEnumerable LegacyTestData => +[ + [1, 2, 3], + [0, 0, 0], +]; +``` + +## TestContext + +The `TestContext` class provides test run information, cancellation support, and output methods. +See [TestContext documentation](https://learn.microsoft.com/dotnet/core/testing/unit-testing-mstest-writing-tests-testcontext) for complete reference. + +### Accessing TestContext + +```csharp +// Property (MSTest suppresses CS8618 - don't use nullable or = null!) +public TestContext TestContext { get; set; } + +// Constructor injection (MSTest 3.6+) - preferred for immutability +[TestClass] +public sealed class MyTests +{ + private readonly TestContext _testContext; + + public MyTests(TestContext testContext) + { + _testContext = testContext; + } +} + +// Static methods receive it as parameter +[ClassInitialize] +public static void ClassInit(TestContext context) { } + +// Optional for cleanup methods (MSTest 3.6+) +[ClassCleanup] +public static void ClassCleanup(TestContext context) { } + +[AssemblyCleanup] +public static void AssemblyCleanup(TestContext context) { } +``` + +### Cancellation Token + +Always use `TestContext.CancellationToken` for cooperative cancellation with `[Timeout]`: + +```csharp +[TestMethod] +[Timeout(5000)] +public async Task LongRunningTest() +{ + await _httpClient.GetAsync(url, TestContext.CancellationToken); +} +``` + +### Test Run Properties + +```csharp +TestContext.TestName // Current test method name +TestContext.TestDisplayName // Display name (3.7+) +TestContext.CurrentTestOutcome // Pass/Fail/InProgress +TestContext.TestData // Parameterized test data (3.7+, in TestInitialize/Cleanup) +TestContext.TestException // Exception if test failed (3.7+, in TestCleanup) +TestContext.DeploymentDirectory // Directory with deployment items +``` + +### Output and Result Files + +```csharp +// Write to test output (useful for debugging) +TestContext.WriteLine("Processing item {0}", itemId); + +// Attach files to test results (logs, screenshots) +TestContext.AddResultFile(screenshotPath); + +// Store/retrieve data across test methods +TestContext.Properties["SharedKey"] = computedValue; +``` + +## Advanced Features + +### Retry for Flaky Tests (MSTest 3.9+) + +```csharp +[TestMethod] +[Retry(3)] +public void FlakyTest() { } +``` + +### Conditional Execution (MSTest 3.10+) + +Skip or run tests based on OS or CI environment: + +```csharp +// OS-specific tests +[TestMethod] +[OSCondition(OperatingSystems.Windows)] +public void WindowsOnlyTest() { } + +[TestMethod] +[OSCondition(OperatingSystems.Linux | OperatingSystems.MacOS)] +public void UnixOnlyTest() { } + +[TestMethod] +[OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] +public void SkipOnWindowsTest() { } + +// CI environment tests +[TestMethod] +[CICondition] // Runs only in CI (default: ConditionMode.Include) +public void CIOnlyTest() { } + +[TestMethod] +[CICondition(ConditionMode.Exclude)] // Skips in CI, runs locally +public void LocalOnlyTest() { } +``` + +### Parallelization + +```csharp +// Assembly level +[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] + +// Disable for specific class +[TestClass] +[DoNotParallelize] +public sealed class SequentialTests { } +``` + +### Work Item Traceability (MSTest 3.8+) + +Link tests to work items for traceability in test reports: + +```csharp +// Azure DevOps work items +[TestMethod] +[WorkItem(12345)] // Links to work item #12345 +public void Feature_Scenario_ExpectedBehavior() { } + +// Multiple work items +[TestMethod] +[WorkItem(12345)] +[WorkItem(67890)] +public void Feature_CoversMultipleRequirements() { } + +// GitHub issues (MSTest 3.8+) +[TestMethod] +[GitHubWorkItem("https://github.com/owner/repo/issues/42")] +public void BugFix_Issue42_IsResolved() { } +``` + +Work item associations appear in test results and can be used for: +- Tracing test coverage to requirements +- Linking bug fixes to regression tests +- Generating traceability reports in CI/CD pipelines + +## Common Mistakes to Avoid + +```csharp +// ❌ Wrong argument order +Assert.AreEqual(actual, expected); +// ✅ Correct +Assert.AreEqual(expected, actual); + +// ❌ Using ExpectedException (obsolete) +[ExpectedException(typeof(ArgumentException))] +// ✅ Use Assert.Throws +Assert.Throws(() => Method()); + +// ❌ Using LINQ Single() - unclear exception +var item = items.Single(); +// ✅ Use ContainsSingle - better failure message +var item = Assert.ContainsSingle(items); + +// ❌ Hard cast - unclear exception +var handler = (MyHandler)result; +// ✅ Type assertion - shows actual type on failure +var handler = Assert.IsInstanceOfType(result); + +// ❌ Ignoring cancellation token +await client.GetAsync(url, CancellationToken.None); +// ✅ Flow test cancellation +await client.GetAsync(url, TestContext.CancellationToken); + +// ❌ Making TestContext nullable - leads to unnecessary null checks +public TestContext? TestContext { get; set; } +// ❌ Using null! - MSTest already suppresses CS8618 for this property +public TestContext TestContext { get; set; } = null!; +// ✅ Declare without nullable or initializer - MSTest handles the warning +public TestContext TestContext { get; set; } +``` + +## Test Organization + +- Group tests by feature or component +- Use `[TestCategory("Category")]` for filtering +- Use `[TestProperty("Name", "Value")]` for custom metadata (e.g., `[TestProperty("Bug", "12345")]`) +- Use `[Priority(1)]` for critical tests +- Enable relevant MSTest analyzers (MSTEST0020 for constructor preference) + +## Mocking and Isolation + +- Use Moq or NSubstitute for mocking dependencies +- Use interfaces to facilitate mocking +- Mock dependencies to isolate units under test diff --git a/MusifyBackend/.agents/skills/csharp-nunit/SKILL.md b/MusifyBackend/.agents/skills/csharp-nunit/SKILL.md new file mode 100644 index 0000000..7890775 --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-nunit/SKILL.md @@ -0,0 +1,71 @@ +--- +name: csharp-nunit +description: 'Get best practices for NUnit unit testing, including data-driven tests' +--- + +# NUnit Best Practices + +Your goal is to help me write effective unit tests with NUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference Microsoft.NET.Test.Sdk, NUnit, and NUnit3TestAdapter packages +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests + +## Test Structure + +- Apply `[TestFixture]` attribute to test classes +- Use `[Test]` attribute for test methods +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use `[SetUp]` and `[TearDown]` for per-test setup and teardown +- Use `[OneTimeSetUp]` and `[OneTimeTearDown]` for per-class setup and teardown +- Use `[SetUpFixture]` for assembly-level setup and teardown + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use clear assertions that express intent +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies + +## Data-Driven Tests + +- Use `[TestCase]` for inline test data +- Use `[TestCaseSource]` for programmatically generated test data +- Use `[Values]` for simple parameter combinations +- Use `[ValueSource]` for property or method-based data sources +- Use `[Random]` for random numeric test values +- Use `[Range]` for sequential numeric test values +- Use `[Combinatorial]` or `[Pairwise]` for combining multiple parameters + +## Assertions + +- Use `Assert.That` with constraint model (preferred NUnit style) +- Use constraints like `Is.EqualTo`, `Is.SameAs`, `Contains.Item` +- Use `Assert.AreEqual` for simple value equality (classic style) +- Use `CollectionAssert` for collection comparisons +- Use `StringAssert` for string-specific assertions +- Use `Assert.Throws` or `Assert.ThrowsAsync` to test exceptions +- Use descriptive messages in assertions for clarity on failure + +## Mocking and Isolation + +- Consider using Moq or NSubstitute alongside NUnit +- Mock dependencies to isolate units under test +- Use interfaces to facilitate mocking +- Consider using a DI container for complex test setups + +## Test Organization + +- Group tests by feature or component +- Use categories with `[Category("CategoryName")]` +- Use `[Order]` to control test execution order when necessary +- Use `[Author("DeveloperName")]` to indicate ownership +- Use `[Description]` to provide additional test information +- Consider `[Explicit]` for tests that shouldn't run automatically +- Use `[Ignore("Reason")]` to temporarily skip tests diff --git a/MusifyBackend/.agents/skills/csharp-tunit/SKILL.md b/MusifyBackend/.agents/skills/csharp-tunit/SKILL.md new file mode 100644 index 0000000..c972ebe --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-tunit/SKILL.md @@ -0,0 +1,100 @@ +--- +name: csharp-tunit +description: 'Get best practices for TUnit unit testing, including data-driven tests' +--- + +# TUnit Best Practices + +Your goal is to help me write effective unit tests with TUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference TUnit package and TUnit.Assertions for fluent assertions +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests +- TUnit requires .NET 8.0 or higher + +## Test Structure + +- No test class attributes required (like xUnit/NUnit) +- Use `[Test]` attribute for test methods (not `[Fact]` like xUnit) +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use lifecycle hooks: `[Before(Test)]` for setup and `[After(Test)]` for teardown +- Use `[Before(Class)]` and `[After(Class)]` for shared context between tests in a class +- Use `[Before(Assembly)]` and `[After(Assembly)]` for shared context across test classes +- TUnit supports advanced lifecycle hooks like `[Before(TestSession)]` and `[After(TestSession)]` + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use TUnit's fluent assertion syntax with `await Assert.That()` +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies (use `[DependsOn]` attribute if needed) + +## Data-Driven Tests + +- Use `[Arguments]` attribute for inline test data (equivalent to xUnit's `[InlineData]`) +- Use `[MethodData]` for method-based test data (equivalent to xUnit's `[MemberData]`) +- Use `[ClassData]` for class-based test data +- Create custom data sources by implementing `ITestDataSource` +- Use meaningful parameter names in data-driven tests +- Multiple `[Arguments]` attributes can be applied to the same test method + +## Assertions + +- Use `await Assert.That(value).IsEqualTo(expected)` for value equality +- Use `await Assert.That(value).IsSameReferenceAs(expected)` for reference equality +- Use `await Assert.That(value).IsTrue()` or `await Assert.That(value).IsFalse()` for boolean conditions +- Use `await Assert.That(collection).Contains(item)` or `await Assert.That(collection).DoesNotContain(item)` for collections +- Use `await Assert.That(value).Matches(pattern)` for regex pattern matching +- Use `await Assert.That(action).Throws()` or `await Assert.That(asyncAction).ThrowsAsync()` to test exceptions +- Chain assertions with `.And` operator: `await Assert.That(value).IsNotNull().And.IsEqualTo(expected)` +- Use `.Or` operator for alternative conditions: `await Assert.That(value).IsEqualTo(1).Or.IsEqualTo(2)` +- Use `.Within(tolerance)` for DateTime and numeric comparisons with tolerance +- All assertions are asynchronous and must be awaited + +## Advanced Features + +- Use `[Repeat(n)]` to repeat tests multiple times +- Use `[Retry(n)]` for automatic retry on failure +- Use `[ParallelLimit]` to control parallel execution limits +- Use `[Skip("reason")]` to skip tests conditionally +- Use `[DependsOn(nameof(OtherTest))]` to create test dependencies +- Use `[Timeout(milliseconds)]` to set test timeouts +- Create custom attributes by extending TUnit's base attributes + +## Test Organization + +- Group tests by feature or component +- Use `[Category("CategoryName")]` for test categorization +- Use `[DisplayName("Custom Test Name")]` for custom test names +- Consider using `TestContext` for test diagnostics and information +- Use conditional attributes like custom `[WindowsOnly]` for platform-specific tests + +## Performance and Parallel Execution + +- TUnit runs tests in parallel by default (unlike xUnit which requires explicit configuration) +- Use `[NotInParallel]` to disable parallel execution for specific tests +- Use `[ParallelLimit]` with custom limit classes to control concurrency +- Tests within the same class run sequentially by default +- Use `[Repeat(n)]` with `[ParallelLimit]` for load testing scenarios + +## Migration from xUnit + +- Replace `[Fact]` with `[Test]` +- Replace `[Theory]` with `[Test]` and use `[Arguments]` for data +- Replace `[InlineData]` with `[Arguments]` +- Replace `[MemberData]` with `[MethodData]` +- Replace `Assert.Equal` with `await Assert.That(actual).IsEqualTo(expected)` +- Replace `Assert.True` with `await Assert.That(condition).IsTrue()` +- Replace `Assert.Throws` with `await Assert.That(action).Throws()` +- Replace constructor/IDisposable with `[Before(Test)]`/`[After(Test)]` +- Replace `IClassFixture` with `[Before(Class)]`/`[After(Class)]` + +**Why TUnit over xUnit?** + +TUnit offers a modern, fast, and flexible testing experience with advanced features not present in xUnit, such as asynchronous assertions, more refined lifecycle hooks, and improved data-driven testing capabilities. TUnit's fluent assertions provide clearer and more expressive test validation, making it especially suitable for complex .NET projects. diff --git a/MusifyBackend/.agents/skills/csharp-xunit/SKILL.md b/MusifyBackend/.agents/skills/csharp-xunit/SKILL.md new file mode 100644 index 0000000..4347c5a --- /dev/null +++ b/MusifyBackend/.agents/skills/csharp-xunit/SKILL.md @@ -0,0 +1,68 @@ +--- +name: csharp-xunit +description: 'Get best practices for XUnit unit testing, including data-driven tests' +--- + +# XUnit Best Practices + +Your goal is to help me write effective unit tests with XUnit, covering both standard and data-driven testing approaches. + +## Project Setup + +- Use a separate test project with naming convention `[ProjectName].Tests` +- Reference Microsoft.NET.Test.Sdk, xunit, and xunit.runner.visualstudio packages +- Create test classes that match the classes being tested (e.g., `CalculatorTests` for `Calculator`) +- Use .NET SDK test commands: `dotnet test` for running tests + +## Test Structure + +- No test class attributes required (unlike MSTest/NUnit) +- Use fact-based tests with `[Fact]` attribute for simple tests +- Follow the Arrange-Act-Assert (AAA) pattern +- Name tests using the pattern `MethodName_Scenario_ExpectedBehavior` +- Use constructor for setup and `IDisposable.Dispose()` for teardown +- Use `IClassFixture` for shared context between tests in a class +- Use `ICollectionFixture` for shared context between multiple test classes + +## Standard Tests + +- Keep tests focused on a single behavior +- Avoid testing multiple behaviors in one test method +- Use clear assertions that express intent +- Include only the assertions needed to verify the test case +- Make tests independent and idempotent (can run in any order) +- Avoid test interdependencies + +## Data-Driven Tests + +- Use `[Theory]` combined with data source attributes +- Use `[InlineData]` for inline test data +- Use `[MemberData]` for method-based test data +- Use `[ClassData]` for class-based test data +- Create custom data attributes by implementing `DataAttribute` +- Use meaningful parameter names in data-driven tests + +## Assertions + +- Use `Assert.Equal` for value equality +- Use `Assert.Same` for reference equality +- Use `Assert.True`/`Assert.False` for boolean conditions +- Use `Assert.Contains`/`Assert.DoesNotContain` for collections +- Use `Assert.Matches`/`Assert.DoesNotMatch` for regex pattern matching +- Use `Assert.Throws` or `await Assert.ThrowsAsync` to test exceptions +- Use fluent assertions library for more readable assertions + +## Mocking and Isolation + +- Consider using Moq or NSubstitute alongside XUnit +- Mock dependencies to isolate units under test +- Use interfaces to facilitate mocking +- Consider using a DI container for complex test setups + +## Test Organization + +- Group tests by feature or component +- Use `[Trait("Category", "CategoryName")]` for categorization +- Use collection fixtures to group tests with shared dependencies +- Consider output helpers (`ITestOutputHelper`) for test diagnostics +- Skip tests conditionally with `Skip = "reason"` in fact/theory attributes diff --git a/MusifyBackend/.agents/skills/dotnet-best-practices/SKILL.md b/MusifyBackend/.agents/skills/dotnet-best-practices/SKILL.md new file mode 100644 index 0000000..183d3be --- /dev/null +++ b/MusifyBackend/.agents/skills/dotnet-best-practices/SKILL.md @@ -0,0 +1,85 @@ +--- +name: dotnet-best-practices +description: 'Ensure .NET/C# code meets best practices for the solution/project.' +--- + +# .NET/C# Best Practices + +Your task is to ensure .NET/C# code in ${selection} meets the best practices specific to this solution/project. This includes: + +## Documentation & Structure + +- Create comprehensive XML documentation comments for all public classes, interfaces, methods, and properties +- Include parameter descriptions and return value descriptions in XML comments +- Follow the established namespace structure: {Core|Console|App|Service}.{Feature} + +## Design Patterns & Architecture + +- Use primary constructor syntax for dependency injection (e.g., `public class MyClass(IDependency dependency)`) +- Implement the Command Handler pattern with generic base classes (e.g., `CommandHandler`) +- Use interface segregation with clear naming conventions (prefix interfaces with 'I') +- Follow the Factory pattern for complex object creation. + +## Dependency Injection & Services + +- Use constructor dependency injection with null checks via ArgumentNullException +- Register services with appropriate lifetimes (Singleton, Scoped, Transient) +- Use Microsoft.Extensions.DependencyInjection patterns +- Implement service interfaces for testability + +## Resource Management & Localization + +- Use ResourceManager for localized messages and error strings +- Separate LogMessages and ErrorMessages resource files +- Access resources via `_resourceManager.GetString("MessageKey")` + +## Async/Await Patterns + +- Use async/await for all I/O operations and long-running tasks +- Return Task or Task from async methods +- Use ConfigureAwait(false) where appropriate +- Handle async exceptions properly + +## Testing Standards + +- Use MSTest framework with FluentAssertions for assertions +- Follow AAA pattern (Arrange, Act, Assert) +- Use Moq for mocking dependencies +- Test both success and failure scenarios +- Include null parameter validation tests + +## Configuration & Settings + +- Use strongly-typed configuration classes with data annotations +- Implement validation attributes (Required, NotEmptyOrWhitespace) +- Use IConfiguration binding for settings +- Support appsettings.json configuration files + +## Semantic Kernel & AI Integration + +- Use Microsoft.SemanticKernel for AI operations +- Implement proper kernel configuration and service registration +- Handle AI model settings (ChatCompletion, Embedding, etc.) +- Use structured output patterns for reliable AI responses + +## Error Handling & Logging + +- Use structured logging with Microsoft.Extensions.Logging +- Include scoped logging with meaningful context +- Throw specific exceptions with descriptive messages +- Use try-catch blocks for expected failure scenarios + +## Performance & Security + +- Use C# 12+ features and .NET 8 optimizations where applicable +- Implement proper input validation and sanitization +- Use parameterized queries for database operations +- Follow secure coding practices for AI/ML operations + +## Code Quality + +- Ensure SOLID principles compliance +- Avoid code duplication through base classes and utilities +- Use meaningful names that reflect domain concepts +- Keep methods focused and cohesive +- Implement proper disposal patterns for resources diff --git a/MusifyBackend/.agents/skills/dotnet-design-pattern-review/SKILL.md b/MusifyBackend/.agents/skills/dotnet-design-pattern-review/SKILL.md new file mode 100644 index 0000000..5d9ded0 --- /dev/null +++ b/MusifyBackend/.agents/skills/dotnet-design-pattern-review/SKILL.md @@ -0,0 +1,42 @@ +--- +name: dotnet-design-pattern-review +description: 'Review the C#/.NET code for design pattern implementation and suggest improvements.' +--- + +# .NET/C# Design Pattern Review + +Review the C#/.NET code in ${selection} for design pattern implementation and suggest improvements for the solution/project. Do not make any changes to the code, just provide a review. + +## Required Design Patterns + +- **Command Pattern**: Generic base classes (`CommandHandler`), `ICommandHandler` interface, `CommandHandlerOptions` inheritance, static `SetupCommand(IHost host)` methods +- **Factory Pattern**: Complex object creation service provider integration +- **Dependency Injection**: Primary constructor syntax, `ArgumentNullException` null checks, interface abstractions, proper service lifetimes +- **Repository Pattern**: Async data access interfaces provider abstractions for connections +- **Provider Pattern**: External service abstractions (database, AI), clear contracts, configuration handling +- **Resource Pattern**: ResourceManager for localized messages, separate .resx files (LogMessages, ErrorMessages) + +## Review Checklist + +- **Design Patterns**: Identify patterns used. Are Command Handler, Factory, Provider, and Repository patterns correctly implemented? Missing beneficial patterns? +- **Architecture**: Follow namespace conventions (`{Core|Console|App|Service}.{Feature}`)? Proper separation between Core/Console projects? Modular and readable? +- **.NET Best Practices**: Primary constructors, async/await with Task returns, ResourceManager usage, structured logging, strongly-typed configuration? +- **GoF Patterns**: Command, Factory, Template Method, Strategy patterns correctly implemented? +- **SOLID Principles**: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion violations? +- **Performance**: Proper async/await, resource disposal, ConfigureAwait(false), parallel processing opportunities? +- **Maintainability**: Clear separation of concerns, consistent error handling, proper configuration usage? +- **Testability**: Dependencies abstracted via interfaces, mockable components, async testability, AAA pattern compatibility? +- **Security**: Input validation, secure credential handling, parameterized queries, safe exception handling? +- **Documentation**: XML docs for public APIs, parameter/return descriptions, resource file organization? +- **Code Clarity**: Meaningful names reflecting domain concepts, clear intent through patterns, self-explanatory structure? +- **Clean Code**: Consistent style, appropriate method/class size, minimal complexity, eliminated duplication? + +## Improvement Focus Areas + +- **Command Handlers**: Validation in base class, consistent error handling, proper resource management +- **Factories**: Dependency configuration, service provider integration, disposal patterns +- **Providers**: Connection management, async patterns, exception handling and logging +- **Configuration**: Data annotations, validation attributes, secure sensitive value handling +- **AI/ML Integration**: Semantic Kernel patterns, structured output handling, model configuration + +Provide specific, actionable recommendations for improvements aligned with the project's architecture and .NET best practices. diff --git a/MusifyBackend/.agents/skills/dotnet-upgrade/SKILL.md b/MusifyBackend/.agents/skills/dotnet-upgrade/SKILL.md new file mode 100644 index 0000000..93ca760 --- /dev/null +++ b/MusifyBackend/.agents/skills/dotnet-upgrade/SKILL.md @@ -0,0 +1,116 @@ +--- +name: dotnet-upgrade +description: 'Ready-to-use prompts for comprehensive .NET framework upgrade analysis and execution' +--- + +# Project Discovery & Assessment + - name: "Project Classification Analysis" + prompt: "Identify all projects in the solution and classify them by type (`.NET Framework`, `.NET Core`, `.NET Standard`). Analyze each `.csproj` for its current `TargetFramework` and SDK usage." + + - name: "Dependency Compatibility Review" + prompt: "Review external and internal dependencies for framework compatibility. Determine the upgrade complexity based on dependency graph depth." + + - name: "Legacy Package Detection" + prompt: "Identify legacy `packages.config` projects needing migration to `PackageReference` format." + + # Upgrade Strategy & Sequencing + - name: "Project Upgrade Ordering" + prompt: "Recommend a project upgrade order from least to most dependent components. Suggest how to isolate class library upgrades before API or Azure Function migrations." + + - name: "Incremental Strategy Planning" + prompt: "Propose an incremental upgrade strategy with rollback checkpoints. Evaluate the use of **Upgrade Assistant** or **manual upgrades** based on project structure." + + - name: "Progress Tracking Setup" + prompt: "Generate an upgrade checklist for tracking build, test, and deployment readiness across all projects." + + # Framework Targeting & Code Adjustments + - name: "Target Framework Selection" + prompt: "Suggest the correct `TargetFramework` for each project (e.g., `net8.0`). Review and update deprecated SDK or build configurations." + + - name: "Code Modernization Analysis" + prompt: "Identify code patterns needing modernization (e.g., `WebHostBuilder` → `HostBuilder`). Suggest replacements for deprecated .NET APIs and third-party libraries." + + - name: "Async Pattern Conversion" + prompt: "Recommend conversion of synchronous calls to async where appropriate for improved performance and scalability." + + # NuGet & Dependency Management + - name: "Package Compatibility Analysis" + prompt: "Analyze outdated or incompatible NuGet packages and suggest compatible versions. Identify third-party libraries that lack .NET 8 support and provide migration paths." + + - name: "Shared Dependency Strategy" + prompt: "Recommend strategies for handling shared dependency upgrades across projects. Evaluate usage of legacy packages and suggest alternatives in Microsoft-supported namespaces." + + - name: "Transitive Dependency Review" + prompt: "Review transitive dependencies and potential version conflicts after upgrade. Suggest resolution strategies for dependency conflicts." + + # CI/CD & Build Pipeline Updates + - name: "Pipeline Configuration Analysis" + prompt: "Analyze YAML build definitions for SDK version pinning and recommend updates. Suggest modifications for `UseDotNet@2` and `NuGetToolInstaller` tasks." + + - name: "Build Pipeline Modernization" + prompt: "Generate updated build pipeline snippets for .NET 8 migration. Recommend validation builds on feature branches before merging to main." + + - name: "CI Automation Enhancement" + prompt: "Identify opportunities to automate test and build verification in CI pipelines. Suggest strategies for continuous integration validation." + + # Testing & Validation + - name: "Build Validation Strategy" + prompt: "Propose validation checks to ensure the upgraded solution builds and runs successfully. Recommend automated test execution for unit and integration suites post-upgrade." + + - name: "Service Integration Verification" + prompt: "Generate validation steps to verify logging, telemetry, and service connectivity. Suggest strategies for verifying backward compatibility and runtime behavior." + + - name: "Deployment Readiness Check" + prompt: "Recommend UAT deployment verification steps before production rollout. Create comprehensive testing scenarios for upgraded components." + + # Breaking Change Analysis + - name: "API Deprecation Detection" + prompt: "Identify deprecated APIs or removed namespaces between target versions. Suggest automated scanning using `.NET Upgrade Assistant` and API Analyzer." + + - name: "API Replacement Strategy" + prompt: "Recommend replacement APIs or libraries for known breaking areas. Review configuration changes such as `Startup.cs` → `Program.cs` refactoring." + + - name: "Regression Testing Focus" + prompt: "Suggest regression testing scenarios focused on upgraded API endpoints or services. Create test plans for critical functionality validation." + + # Version Control & Commit Strategy + - name: "Branching Strategy Planning" + prompt: "Recommend branching strategy for safe upgrade with rollback capability. Generate commit templates for partial and complete project upgrades." + + - name: "PR Structure Optimization" + prompt: "Suggest best practices for creating structured PRs (`Upgrade to .NET [Version]`). Identify tagging strategies for PRs involving breaking changes." + + - name: "Code Review Guidelines" + prompt: "Recommend peer review focus areas (build, test, and dependency validation). Create checklists for effective upgrade reviews." + + # Documentation & Communication + - name: "Upgrade Documentation Strategy" + prompt: "Suggest how to document each project's framework change in the PR. Propose automated release note generation summarizing upgrades and test results." + + - name: "Stakeholder Communication" + prompt: "Recommend communicating version upgrades and migration timelines to consumers. Generate documentation templates for dependency updates and validation results." + + - name: "Progress Tracking Systems" + prompt: "Suggest maintaining an upgrade summary dashboard or markdown checklist. Create templates for tracking upgrade progress across multiple projects." + + # Tools & Automation + - name: "Upgrade Tool Selection" + prompt: "Recommend when and how to use: `.NET Upgrade Assistant`, `dotnet list package --outdated`, `dotnet migrate`, and `graph.json` dependency visualization." + + - name: "Analysis Script Generation" + prompt: "Generate scripts or prompts for analyzing dependency graphs before upgrading. Propose AI-assisted prompts for Copilot to identify upgrade issues automatically." + + - name: "Multi-Repository Validation" + prompt: "Suggest how to validate automation output across multiple repositories. Create standardized validation workflows for enterprise-scale upgrades." + + # Final Validation & Delivery + - name: "Final Solution Validation" + prompt: "Generate validation steps to confirm the final upgraded solution passes all validation checks. Suggest production deployment verification steps post-upgrade." + + - name: "Deployment Readiness Confirmation" + prompt: "Recommend generating final test results and build artifacts. Create a checklist summarizing completion across projects (builds/tests/deployment)." + + - name: "Release Documentation" + prompt: "Generate a release note summarizing framework changes and CI/CD updates. Create comprehensive upgrade summary documentation." + +--- diff --git a/MusifyBackend/.github/copilot-instructions.md b/MusifyBackend/.github/copilot-instructions.md new file mode 100644 index 0000000..c13be9d --- /dev/null +++ b/MusifyBackend/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos. \ No newline at end of file diff --git a/MusifyBackend/.gitignore b/MusifyBackend/.gitignore new file mode 100644 index 0000000..ba7a514 --- /dev/null +++ b/MusifyBackend/.gitignore @@ -0,0 +1,428 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates +*.env + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ + +[Dd]ebug/x64/ +[Dd]ebugPublic/x64/ +[Rr]elease/x64/ +[Rr]eleases/x64/ +bin/x64/ +obj/x64/ + +[Dd]ebug/x86/ +[Dd]ebugPublic/x86/ +[Rr]elease/x86/ +[Rr]eleases/x86/ +bin/x86/ +obj/x86/ + +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +[Aa][Rr][Mm]64[Ee][Cc]/ +bld/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Build results on 'Bin' directories +**/[Bb]in/* +# Uncomment if you have tasks that rely on *.refresh files to move binaries +# (https://github.com/github/gitignore/pull/3736) +#!**/[Bb]in/*.refresh + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* +*.trx + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Approval Tests result files +*.received.* + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.idb +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +**/.paket/paket.exe +paket-files/ + +# FAKE - F# Make +**/.fake/ + +# CodeRush personal settings +**/.cr/personal + +# Python Tools for Visual Studio (PTVS) +**/__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +#tools/** +#!tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog +MSBuild_Logs/ + +# AWS SAM Build and Temporary Artifacts folder +.aws-sam + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +**/.mfractor/ + +# Local History for Visual Studio +**/.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +**/.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp \ No newline at end of file diff --git a/MusifyBackend/Application/Application.csproj b/MusifyBackend/Application/Application.csproj new file mode 100644 index 0000000..c64c6c1 --- /dev/null +++ b/MusifyBackend/Application/Application.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + Musify.Application + Musify.Application + 0.1.2 + NotS1mp + https://github.com/XShine555/MusifyBackend + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/MusifyBackend/Application/Configuration/ApplicationStorageConfiguration.cs b/MusifyBackend/Application/Configuration/ApplicationStorageConfiguration.cs new file mode 100644 index 0000000..1458cc3 --- /dev/null +++ b/MusifyBackend/Application/Configuration/ApplicationStorageConfiguration.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class ApplicationStorageConfiguration + { + public const string SectionName = "ApplicationStorage"; + + [Required] + [StringLength(128, MinimumLength = 1)] + public required string Bucket { get; set; } + } +} diff --git a/MusifyBackend/Application/Configuration/ConfigurationDependencyInjection.cs b/MusifyBackend/Application/Configuration/ConfigurationDependencyInjection.cs new file mode 100644 index 0000000..a4687af --- /dev/null +++ b/MusifyBackend/Application/Configuration/ConfigurationDependencyInjection.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Musify.Application.Configuration +{ + public static class ConfigurationDependencyInjection + { + public static IServiceCollection AddApplicationStorageConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(ApplicationStorageConfiguration.SectionName)) + .ValidateDataAnnotations(); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } + + public static IServiceCollection AddPlayListConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(PlayListConfiguration.SectionName)) + .ValidateDataAnnotations(); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } + + public static IServiceCollection AddTrackConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(TrackConfiguration.SectionName)) + .ValidateDataAnnotations(); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } + + public static IServiceCollection AddStreamGatewayConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(StreamGatewayConfiguration.SectionName)) + .ValidateDataAnnotations(); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return serviceDescriptors; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Configuration/PlayListConfiguration.cs b/MusifyBackend/Application/Configuration/PlayListConfiguration.cs new file mode 100644 index 0000000..39e0d39 --- /dev/null +++ b/MusifyBackend/Application/Configuration/PlayListConfiguration.cs @@ -0,0 +1,97 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Application.Shared; + +namespace Musify.Application.Configuration +{ + public class PlayListConfiguration + { + public const string SectionName = "PlayList"; + + [Required] + public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); + + [Required] + public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); + } + + public class PlayListRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + + [Required] + public string ParentFolders { get; set; } = "PlayLists"; + + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + [Required] + public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; + + [Required] + public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; + + [Required] + public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; + + [Required] + public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; + + public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); + + public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); + + public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); + + public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, PresetSmallPicture); + + public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, PresetMediumPicture); + + public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, PresetLargePicture); + + public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); + + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); + + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); + + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); + + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); + + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); + } + + public class PlayListPicturesSizes + { + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Configuration/StreamGatewayConfiguration.cs b/MusifyBackend/Application/Configuration/StreamGatewayConfiguration.cs new file mode 100644 index 0000000..edba302 --- /dev/null +++ b/MusifyBackend/Application/Configuration/StreamGatewayConfiguration.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class StreamGatewayConfiguration + { + public const string SectionName = "StreamGateway"; + + [Url] + [Required] + public required string PublicBaseUrl { get; set; } + + [Required] + public string AudioFileName { get; set; } = "audio.m4a"; + } +} diff --git a/MusifyBackend/Application/Configuration/TrackConfiguration.cs b/MusifyBackend/Application/Configuration/TrackConfiguration.cs new file mode 100644 index 0000000..1304e29 --- /dev/null +++ b/MusifyBackend/Application/Configuration/TrackConfiguration.cs @@ -0,0 +1,116 @@ +using System.ComponentModel.DataAnnotations; +using Musify.Application.Shared; + +namespace Musify.Application.Configuration +{ + public class TrackConfiguration + { + public const string SectionName = "Track"; + + [Required] + public TrackRoutes Routes { get; set; } = new TrackRoutes(); + + [Required] + public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); + } + + public class TrackRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; + + [Required] + public string ParentFolder { get; set; } = "Tracks"; + + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + [Required] + public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; + + [Required] + public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; + + [Required] + public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; + + [Required] + public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; + + [Required] + public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; + + [Required] + public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; + + public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); + + public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); + + public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); + + public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); + + public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); + + public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); + + public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); + + public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); + + public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); + + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); + + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); + + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); + + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); + + public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName); + + public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName); + + public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName); + + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); + + public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); + } + + public class TrackPicturesSizes + { + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Configuration/UploadIntentConfiguration.cs b/MusifyBackend/Application/Configuration/UploadIntentConfiguration.cs new file mode 100644 index 0000000..b18d32d --- /dev/null +++ b/MusifyBackend/Application/Configuration/UploadIntentConfiguration.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + public class UploadIntentConfiguration + { + public const string SectionName = "UploadIntent"; + + [Range(10, 3600)] + public int UploadUrlExpiresInSeconds { get; set; } = 120; + + [Range(1, 100)] + public int MaxActiveUploadIntentsPerUser { get; set; } = 5; + + [Range(1, long.MaxValue)] + public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; + + [Range(1, long.MaxValue)] + public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; + + [Range(1, long.MaxValue)] + public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; + + [Range(1, long.MaxValue)] + public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; + + [Range(30, 86400)] + public int ExpirationJobIntervalSeconds { get; set; } = 300; + + [Range(0, 365)] + public int ExpiredIntentsRetentionDays { get; set; } = 7; + + [Required, MinLength(1)] + public string TemporalRootPrefix { get; set; } = "temporal"; + + [Range(1, 30)] + public int TemporalUploadsRetentionDays { get; set; } = 3; + + [Range(60, 86400)] + public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; + } +} diff --git a/MusifyBackend/Application/Contracts/IAudioTranscoderService.cs b/MusifyBackend/Application/Contracts/IAudioTranscoderService.cs new file mode 100644 index 0000000..c4f0d02 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IAudioTranscoderService.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Contracts +{ + public interface IAudioTranscoderService + { + Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + + Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Contracts/IDatabase.cs b/MusifyBackend/Application/Contracts/IDatabase.cs new file mode 100644 index 0000000..8fbaf26 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IDatabase.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; + +namespace Musify.Application.Contracts +{ + public interface IDatabase + { + DbSet Users { get; } + + DbSet PlayLists { get; } + + DbSet Tracks { get; } + + DbSet UserHasTracks { get; } + + DbSet PlayListHasTracks { get; } + + DbSet Uploads { get; } + + DbSet UploadIntents { get; } + + Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Contracts/IDatabaseTransaction.cs b/MusifyBackend/Application/Contracts/IDatabaseTransaction.cs new file mode 100644 index 0000000..42e6185 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IDatabaseTransaction.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Contracts +{ + public interface IDatabaseTransaction : IAsyncDisposable + { + Task CommitAsync(CancellationToken cancellationToken = default); + } +} diff --git a/MusifyBackend/Application/Contracts/IEventBus.cs b/MusifyBackend/Application/Contracts/IEventBus.cs new file mode 100644 index 0000000..1f5acb9 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IEventBus.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Contracts +{ + public interface IEventBus + { + Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Contracts/IPictureService.cs b/MusifyBackend/Application/Contracts/IPictureService.cs new file mode 100644 index 0000000..8c8ccd2 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IPictureService.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Contracts +{ + public interface IPictureService + { + Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Contracts/IStorageService.cs b/MusifyBackend/Application/Contracts/IStorageService.cs new file mode 100644 index 0000000..02fe1f1 --- /dev/null +++ b/MusifyBackend/Application/Contracts/IStorageService.cs @@ -0,0 +1,28 @@ +namespace Musify.Application.Contracts +{ + public interface IStorageService + { + Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + + Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + + Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); + + Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); + + Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); + + Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); + + Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); + + Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); + + Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + CancellationToken cancellationToken); + + Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); + + IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Contracts/IStreamTicketService.cs b/MusifyBackend/Application/Contracts/IStreamTicketService.cs new file mode 100644 index 0000000..f72282b --- /dev/null +++ b/MusifyBackend/Application/Contracts/IStreamTicketService.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Contracts +{ + public interface IStreamTicketService + { + StreamTicket IssueTicket(long userId, string keyPrefix); + } + + public record StreamTicket(string Token, int ExpiresInSeconds); +} diff --git a/MusifyBackend/Application/Contracts/ObjectMetaData.cs b/MusifyBackend/Application/Contracts/ObjectMetaData.cs new file mode 100644 index 0000000..6406463 --- /dev/null +++ b/MusifyBackend/Application/Contracts/ObjectMetaData.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Contracts +{ + public record ObjectMetaData(string ContentType, long ContentLength); +} diff --git a/MusifyBackend/Application/Contracts/StorageObject.cs b/MusifyBackend/Application/Contracts/StorageObject.cs new file mode 100644 index 0000000..2ec4d45 --- /dev/null +++ b/MusifyBackend/Application/Contracts/StorageObject.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Contracts +{ + public record StorageObject(string Key, DateTime LastModifiedUtc); +} diff --git a/MusifyBackend/Application/Events/CreatePlayListResourcesEvent.cs b/MusifyBackend/Application/Events/CreatePlayListResourcesEvent.cs new file mode 100644 index 0000000..80266cb --- /dev/null +++ b/MusifyBackend/Application/Events/CreatePlayListResourcesEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record CreatePlayListResourcesEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/MusifyBackend/Application/Events/CreateTrackResourcesEvent.cs b/MusifyBackend/Application/Events/CreateTrackResourcesEvent.cs new file mode 100644 index 0000000..e7f08b5 --- /dev/null +++ b/MusifyBackend/Application/Events/CreateTrackResourcesEvent.cs @@ -0,0 +1,18 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record CreateTrackResourcesEvent( + Guid TrackId, + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + string AudioSourceKey, + string AudioDestinationKey, + string AudioProcessedFolderKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/MusifyBackend/Application/Events/DeletePlayListEvent.cs b/MusifyBackend/Application/Events/DeletePlayListEvent.cs new file mode 100644 index 0000000..06a7a2c --- /dev/null +++ b/MusifyBackend/Application/Events/DeletePlayListEvent.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Events +{ + public record DeletePlayListEvent( + Guid PlayListId, + long UserId); +} diff --git a/MusifyBackend/Application/Events/DeleteTrackEvent.cs b/MusifyBackend/Application/Events/DeleteTrackEvent.cs new file mode 100644 index 0000000..8fb6923 --- /dev/null +++ b/MusifyBackend/Application/Events/DeleteTrackEvent.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Events +{ + public record DeleteTrackEvent( + Guid TrackId, + long UserId); +} diff --git a/MusifyBackend/Application/Events/UpdatePlayListPictureEvent.cs b/MusifyBackend/Application/Events/UpdatePlayListPictureEvent.cs new file mode 100644 index 0000000..d617c75 --- /dev/null +++ b/MusifyBackend/Application/Events/UpdatePlayListPictureEvent.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureEvent( + Guid PlayListId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} \ No newline at end of file diff --git a/MusifyBackend/Application/Events/UpdatePlayListPictureSourceEvent.cs b/MusifyBackend/Application/Events/UpdatePlayListPictureSourceEvent.cs new file mode 100644 index 0000000..24c7101 --- /dev/null +++ b/MusifyBackend/Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureSourceEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/MusifyBackend/Application/Events/UpdateTrackAudioEvent.cs b/MusifyBackend/Application/Events/UpdateTrackAudioEvent.cs new file mode 100644 index 0000000..30c7ede --- /dev/null +++ b/MusifyBackend/Application/Events/UpdateTrackAudioEvent.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Events +{ + public record UpdateTrackAudioEvent( + Guid TrackId, + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationFolderKey); +} \ No newline at end of file diff --git a/MusifyBackend/Application/Events/UpdateTrackPictureEvent.cs b/MusifyBackend/Application/Events/UpdateTrackPictureEvent.cs new file mode 100644 index 0000000..8973e64 --- /dev/null +++ b/MusifyBackend/Application/Events/UpdateTrackPictureEvent.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Application.Events +{ + public record UpdateTrackPictureEvent( + Guid TrackId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} \ No newline at end of file diff --git a/MusifyBackend/Application/Extensions/ResultExtensions.cs b/MusifyBackend/Application/Extensions/ResultExtensions.cs new file mode 100644 index 0000000..04bf866 --- /dev/null +++ b/MusifyBackend/Application/Extensions/ResultExtensions.cs @@ -0,0 +1,25 @@ +using Ardalis.Result; + +namespace Musify.Application.Extensions; + +public static class ResultExtensions +{ + public static Result As(this Result result) + { + return result.Status switch + { + ResultStatus.Ok or + ResultStatus.Created or + ResultStatus.NoContent => throw new InvalidOperationException($"Cannot convert a successful result ( {result.Status} ) to another type. Use Map or Bind instead."), + ResultStatus.NotFound => Result.NotFound(result.Errors.ToArray()), + ResultStatus.Forbidden => Result.Forbidden(result.Errors.ToArray()), + ResultStatus.Unauthorized => Result.Unauthorized(result.Errors.ToArray()), + ResultStatus.Invalid => Result.Invalid(result.ValidationErrors.ToArray()), + ResultStatus.Conflict => Result.Conflict(result.Errors.ToArray()), + ResultStatus.CriticalError => Result.CriticalError(result.Errors.ToArray()), + ResultStatus.Unavailable => Result.Unavailable(result.Errors.ToArray()), + ResultStatus.Error => Result.Error(string.Join(";", result.Errors)), + _ => Result.Error(string.Join(";", result.Errors)) + }; + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/MusifyApplication.cs b/MusifyBackend/Application/MusifyApplication.cs new file mode 100644 index 0000000..845ff9b --- /dev/null +++ b/MusifyBackend/Application/MusifyApplication.cs @@ -0,0 +1,4 @@ +using Mediator; +using Microsoft.Extensions.DependencyInjection; + +[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] \ No newline at end of file diff --git a/MusifyBackend/Application/Pagination/PaginatedResponse.cs b/MusifyBackend/Application/Pagination/PaginatedResponse.cs new file mode 100644 index 0000000..57a24ab --- /dev/null +++ b/MusifyBackend/Application/Pagination/PaginatedResponse.cs @@ -0,0 +1,26 @@ +using X.PagedList; + +namespace Musify.Application.Pagination +{ + public record PaginatedResponse( + IReadOnlyCollection Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage) + { + public static PaginatedResponse FromPagedList(IPagedList pagedList) + { + return new PaginatedResponse( + pagedList.ToArray(), + pagedList.PageNumber, + pagedList.PageSize, + pagedList.PageCount, + pagedList.TotalItemCount, + pagedList.HasNextPage, + pagedList.HasPreviousPage); + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Commands/AddTrackToPlayListCommand.cs b/MusifyBackend/Application/PlayLists/Commands/AddTrackToPlayListCommand.cs new file mode 100644 index 0000000..3d5e96b --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/AddTrackToPlayListCommand.cs @@ -0,0 +1,8 @@ +using Ardalis.Result; +using Mediator; + +namespace Musify.Application.PlayLists.Commands +{ + public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand; +} diff --git a/MusifyBackend/Application/PlayLists/Commands/CreatePlayListCommand.cs b/MusifyBackend/Application/PlayLists/Commands/CreatePlayListCommand.cs new file mode 100644 index 0000000..95b3788 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/CreatePlayListCommand.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Commands +{ + public record CreatePlayListCommand( + long UserId, + string Name, + string Description, + Guid? PictureIntentId) + : ICommand>; +} diff --git a/MusifyBackend/Application/PlayLists/Commands/DeletePlayListCommand.cs b/MusifyBackend/Application/PlayLists/Commands/DeletePlayListCommand.cs new file mode 100644 index 0000000..c1860c5 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/DeletePlayListCommand.cs @@ -0,0 +1,8 @@ +using Ardalis.Result; +using Mediator; + +namespace Musify.Application.PlayLists.Commands +{ + public record DeletePlayListCommand(long UserId, Guid PlayListId) + : ICommand; +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Commands/RemoveTrackFromPlayListCommand.cs b/MusifyBackend/Application/PlayLists/Commands/RemoveTrackFromPlayListCommand.cs new file mode 100644 index 0000000..991267d --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/RemoveTrackFromPlayListCommand.cs @@ -0,0 +1,8 @@ +using Ardalis.Result; +using Mediator; + +namespace Musify.Application.PlayLists.Commands +{ + public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand; +} diff --git a/MusifyBackend/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs b/MusifyBackend/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs new file mode 100644 index 0000000..7f8a827 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/RequestPlayListPictureUploadCommand.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Commands +{ + public record RequestPlayListPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; +} diff --git a/MusifyBackend/Application/PlayLists/Commands/UpdatePlayListCommand.cs b/MusifyBackend/Application/PlayLists/Commands/UpdatePlayListCommand.cs new file mode 100644 index 0000000..a2bfe18 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Commands/UpdatePlayListCommand.cs @@ -0,0 +1,14 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Commands +{ + public record UpdatePlayListCommand( + long UserId, + Guid PlayListId, + string? NewName, + string? NewDescription, + Guid? NewPictureIntentId) + : ICommand>; +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/AddTrackToPlayListCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/AddTrackToPlayListCommandHandler.cs new file mode 100644 index 0000000..c078f9f --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/AddTrackToPlayListCommandHandler.cs @@ -0,0 +1,70 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Commands; +using Musify.Domain.Entities; + +namespace Musify.Application.PlayLists.Handlers +{ + public class AddTrackToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList is null) + { + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Result.NotFound(); + } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Result.Unauthorized(); + } + + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Result.NotFound($"Track {request.TrackId} not found"); + } + + var alreadyAdded = await database.PlayListHasTracks + .AsNoTracking() + .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + if (alreadyAdded) + { + logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId); + return Result.Conflict("Track is already in the playlist."); + } + + var nextPosition = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .Select(plt => (int?)plt.Position) + .MaxAsync(cancellationToken) + 1 ?? 0; + + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = request.TrackId, + Position = nextPosition + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", + request.TrackId, request.PlayListId, nextPosition); + + return Result.Success(); + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs new file mode 100644 index 0000000..9405a36 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/CreatePlayListCommandHandler.cs @@ -0,0 +1,131 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Extensions; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class CreatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + UploadIntent? pictureIntent = null; + string originalPictureName; + + if (request.PictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.PictureIntentId.Value, request.UserId, cancellationToken); + if (!validation.IsSuccess) + return validation.As(); + + pictureIntent = validation.Value; + originalPictureName = pictureIntent.ObjectName; + } + else + { + originalPictureName = playListConfiguration.Routes.PresetOriginalPicture; + } + + var playList = new PlayList + { + UserId = request.UserId, + Name = request.Name, + NormalizedName = request.Name.Trim().ToUpperInvariant(), + Description = request.Description, + OriginalPictureName = originalPictureName, + SmallPictureName = playListConfiguration.Routes.PresetSmallPicture, + MediumPictureName = playListConfiguration.Routes.PresetMediumPicture, + LargePictureName = playListConfiguration.Routes.PresetLargePicture + }; + + await database.PlayLists.AddAsync(playList, cancellationToken); + + if (pictureIntent is not null) + { + var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, originalPictureName); + var publishResult = await PublishCreatePlayListEventAsync( + playList.Id, pictureIntent, finalPictureKey, cancellationToken); + if (!publishResult.IsSuccess) + return publishResult; + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId); + return Result.Error($"Failed to create playlist for user {request.UserId}"); + } + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + return Result.Created(PlayListApplicationResponse.FromEntity(playList)); + } + + async Task> PublishCreatePlayListEventAsync( + Guid playListId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new CreatePlayListResourcesEvent( + playListId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + playListConfiguration.Routes.SmallPicturesPath, + playListConfiguration.PicturesSizes.SmallPictureWidth, + playListConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + playListConfiguration.Routes.MediumPicturesPath, + playListConfiguration.PicturesSizes.MediumPictureWidth, + playListConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + playListConfiguration.Routes.LargePicturesPath, + playListConfiguration.PicturesSizes.LargePictureWidth, + playListConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return Result.Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId); + return Result.Error($"Failed to publish create playlist event for playlist {playListId}"); + } + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs new file mode 100644 index 0000000..459066f --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/DeletePlayListCommandHandler.cs @@ -0,0 +1,60 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.PlayLists.Handlers +{ + public class DeletePlayListCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); + if (playList is null) + { + logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); + return Result.NotFound(); + } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); + return Result.Unauthorized(); + } + + playList.LifeCycleStatus = LifeCycleStatus.Removing; + + try + { + await eventBus.PublishAsync( + new DeletePlayListEvent(playList.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); + return Result.Error($"Failed to delete playlist {request.PlayListId}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); + return Result.Error($"Failed to delete playlist {request.PlayListId}"); + } + + return Result.NoContent(); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs new file mode 100644 index 0000000..5fc3439 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListByIdQueryHandler.cs @@ -0,0 +1,26 @@ +using Ardalis.Result; +using Microsoft.EntityFrameworkCore; +using Musify.Application.PlayLists.Responses; +using Musify.Application.PlayLists.Queries; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class GetPlayListByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == request.Id) + .Select(p => PlayListApplicationResponse.FromEntity(p)) + .SingleOrDefaultAsync(cancellationToken); + + return playList is null + ? Result.NotFound() + : Result.Success(playList); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Handlers/GetPlayListTracksQueryHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListTracksQueryHandler.cs new file mode 100644 index 0000000..6f9a3eb --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListTracksQueryHandler.cs @@ -0,0 +1,38 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Pagination; +using Musify.Application.PlayLists.Queries; +using Musify.Application.Tracks.Responses; +using X.PagedList.EF; + +namespace Musify.Application.PlayLists.Handlers +{ + public class GetPlayListTracksQueryHandler(IDatabase database) + : IQueryHandler> > + { + public async ValueTask >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) + { + var playListExists = await database.PlayLists + .AsNoTracking() + .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); + if (!playListExists) + return Result>.NotFound(); + + var tracksQuery = database.PlayListHasTracks + .AsNoTracking() + .Include(plt => plt.Track) + .Where(plt => plt.PlayListId == request.PlayListId); + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedTracks = await tracksQuery + .OrderBy(plt => plt.Position) + .Select(plt => TrackApplicationResponse.FromEntity(plt.Track)) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedTracks)); + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsByUserIdQueryHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsByUserIdQueryHandler.cs new file mode 100644 index 0000000..d4b571c --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsByUserIdQueryHandler.cs @@ -0,0 +1,37 @@ +using Ardalis.Result; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Pagination; +using Musify.Application.PlayLists.Responses; +using Musify.Application.PlayLists.Queries; +using X.PagedList.EF; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class GetPlayListsByUserIdQueryHandler(IDatabase database) + : IQueryHandler > > + { + public async ValueTask >> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + { + var playListsQuery = database.PlayLists + .AsNoTracking() + .Where(p => p.UserId == request.UserId); + + if (request.Name is not null) + { + var normalizedName = request.Name.Trim().ToUpperInvariant(); + playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); + } + + var totalCount = await playListsQuery.CountAsync(cancellationToken); + + var pagedPlayLists = await playListsQuery + .OrderBy(p => p.CreatedAt) + .Select(p => PlayListApplicationResponse.FromEntity(p)) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedPlayLists)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsQueryHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsQueryHandler.cs new file mode 100644 index 0000000..a2bf593 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/GetPlayListsQueryHandler.cs @@ -0,0 +1,28 @@ +using Ardalis.Result; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Pagination; +using Musify.Application.PlayLists.Responses; +using Musify.Application.PlayLists.Queries; +using X.PagedList.EF; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class GetPlayListsQueryHandler(IDatabase database) + : IQueryHandler >> + { + public async ValueTask >> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) + { + var totalCount = await database.PlayLists.CountAsync(cancellationToken); + + var pagedPlayLists = await database.PlayLists + .AsNoTracking() + .OrderBy(p => p.Id) + .Select(p => PlayListApplicationResponse.FromEntity(p)) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedPlayLists)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Handlers/RemoveTrackFromPlayListCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/RemoveTrackFromPlayListCommandHandler.cs new file mode 100644 index 0000000..609ede9 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/RemoveTrackFromPlayListCommandHandler.cs @@ -0,0 +1,47 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.PlayLists.Commands; + +namespace Musify.Application.PlayLists.Handlers +{ + public class RemoveTrackFromPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList is null) + { + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Result.NotFound(); + } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Result.Unauthorized(); + } + + var link = await database.PlayListHasTracks + .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + if (link is null) + { + logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); + return Result.NotFound("Track is not in the playlist."); + } + + database.PlayListHasTracks.Remove(link); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); + + return Result.Success(); + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs new file mode 100644 index 0000000..ff0ef0d --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/RequestPlayListPictureUploadCommandHandler.cs @@ -0,0 +1,99 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class RequestPlayListPictureUploadCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + var effectiveSizeBytes = request.ExpectedSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + + var objectName = $"{Guid.NewGuid() }.{request.FileType.TrimStart('.').ToLowerInvariant() }"; + + var tempKey = playListConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempKey, + request.ContentType, + TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + cancellationToken); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectiveSizeBytes, 1, cancellationToken); + if (!quotaCheck.IsSuccess) + { + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck; + } + + var intent = new UploadIntent + { + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempKey, + ObjectName = objectName, + ContentType = request.ContentType, + ExpectedSizeBytes = effectiveSizeBytes, + Purpose = UploadIntentPurpose.PlayListPicture, + ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); + + return Result.Success(new PlayListPictureUploadResponse( + intent.Id, + storageConfiguration.Bucket, + tempKey, + objectName, + request.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + uploadUrl)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId); + return Result.Error("Failed to generate upload URL"); + } + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs b/MusifyBackend/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs new file mode 100644 index 0000000..e8b9317 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Handlers/UpdatePlayListCommandHandler.cs @@ -0,0 +1,130 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Application.PlayLists.Commands; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Extensions; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Application.Contracts; + +namespace Musify.Application.PlayLists.Handlers +{ + public class UpdatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) + { + var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playListEntity is null) + { + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Result.NotFound(); + } + + if (playListEntity.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Result.Unauthorized(); + } + + if (request.NewName is not null) + { + playListEntity.Name = request.NewName; + playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + } + + if (request.NewDescription is not null) + { + playListEntity.Description = request.NewDescription; + } + + UploadIntent? pictureIntent = null; + string? finalPictureKey = null; + + if (request.NewPictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.NewPictureIntentId.Value, request.UserId, cancellationToken); + if (!validation.IsSuccess) + return validation.As(); + + pictureIntent = validation.Value; + playListEntity.OriginalPictureName = pictureIntent.ObjectName; + finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + } + + database.PlayLists.Update(playListEntity); + + if (pictureIntent is not null && finalPictureKey is not null) + { + var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( + playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken); + if (!publishResult.IsSuccess) + return publishResult; + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id); + return Result.Error($"Failed to update playlist {playListEntity.Id}"); + } + + logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); + return Result.Success(PlayListApplicationResponse.FromEntity(playListEntity)); + } + + async Task> PublishUpdatePlayListPictureSourceEventAsync( + Guid playListId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new UpdatePlayListPictureSourceEvent( + playListId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + playListConfiguration.Routes.SmallPicturesPath, + playListConfiguration.PicturesSizes.SmallPictureWidth, + playListConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + playListConfiguration.Routes.MediumPicturesPath, + playListConfiguration.PicturesSizes.MediumPictureWidth, + playListConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + playListConfiguration.Routes.LargePicturesPath, + playListConfiguration.PicturesSizes.LargePictureWidth, + playListConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return Result.Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId); + return Result.Error($"Failed to publish update playlist picture source event for playlist {playListId}"); + } + } + } +} diff --git a/MusifyBackend/Application/PlayLists/Queries/GetPlayListByIdQuery.cs b/MusifyBackend/Application/PlayLists/Queries/GetPlayListByIdQuery.cs new file mode 100644 index 0000000..55d98c5 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Queries/GetPlayListByIdQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Queries +{ + public record GetPlayListByIdQuery(Guid Id) + : IQuery>; +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Queries/GetPlayListTracksQuery.cs b/MusifyBackend/Application/PlayLists/Queries/GetPlayListTracksQuery.cs new file mode 100644 index 0000000..49759ef --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Queries/GetPlayListTracksQuery.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.PlayLists.Queries +{ + public record GetPlayListTracksQuery( + Guid PlayListId, + int PageNumber = 1, + int PageSize = 10) + : IQuery>>; +} diff --git a/MusifyBackend/Application/PlayLists/Queries/GetPlayListsByUserIdQuery.cs b/MusifyBackend/Application/PlayLists/Queries/GetPlayListsByUserIdQuery.cs new file mode 100644 index 0000000..1c28036 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Queries/GetPlayListsByUserIdQuery.cs @@ -0,0 +1,14 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Queries +{ + public record GetPlayListsByUserIdQuery( + long UserId, + string? Name, + int PageNumber = 1, + int PageSize = 10) + : IQuery> >; +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Queries/GetPlayListsQuery.cs b/MusifyBackend/Application/PlayLists/Queries/GetPlayListsQuery.cs new file mode 100644 index 0000000..18d7b44 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Queries/GetPlayListsQuery.cs @@ -0,0 +1,10 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.PlayLists.Responses; + +namespace Musify.Application.PlayLists.Queries +{ + public record GetPlayListsQuery(int PageNumber = 1, int PageSize = 10) + : IQuery> >; +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Responses/PlayListApplicationResponse.cs b/MusifyBackend/Application/PlayLists/Responses/PlayListApplicationResponse.cs new file mode 100644 index 0000000..6a577b4 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -0,0 +1,28 @@ +using Musify.Domain.Entities; + +namespace Musify.Application.PlayLists.Responses +{ + public record PlayListApplicationResponse( + Guid Id, + string Name, + string Description, + string SmallImageKeyName, + string MediumImageKeyName, + string LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt) + { + public static PlayListApplicationResponse FromEntity(PlayList playList) + { + return new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.SmallPictureName, + playList.MediumPictureName, + playList.LargePictureName, + playList.CreatedAt, + playList.UpdatedAt); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/MusifyBackend/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs new file mode 100644 index 0000000..7928680 --- /dev/null +++ b/MusifyBackend/Application/PlayLists/Responses/PlayListPictureUploadResponse.cs @@ -0,0 +1,11 @@ +namespace Musify.Application.PlayLists.Responses +{ + public record PlayListPictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/MusifyBackend/Application/Services/UploadIntentValidator.cs b/MusifyBackend/Application/Services/UploadIntentValidator.cs new file mode 100644 index 0000000..c3efaa5 --- /dev/null +++ b/MusifyBackend/Application/Services/UploadIntentValidator.cs @@ -0,0 +1,63 @@ +using Ardalis.Result; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Services; + +public sealed class UploadIntentValidator( + IDatabase database, + IStorageService storageService) +{ + public async Task CheckQuotaAsync( + UploadIntentConfiguration config, + long userId, + long requiredBytes, + int requiredIntentCount, + CancellationToken cancellationToken) + { + var activeIntents = await database.UploadIntents + .AsNoTracking() + .Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued) + .ToListAsync(cancellationToken); + + if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + return Result.Invalid(new ValidationError("Upload intent limit exceeded. Wait for existing uploads to complete or expire.")); + + var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes); + if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser) + return Result.Invalid(new ValidationError("Upload byte quota exceeded. Wait for existing uploads to complete or expire.")); + + return Result.Success(); + } + + public async Task> ValidateAndLoadAsync( + UploadIntentConfiguration config, + Guid intentId, + long userId, + CancellationToken cancellationToken) + { + var intent = await database.UploadIntents + .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); + + if (intent is null || intent.UserId != userId) + return Result.NotFound("Upload intent not found or not accessible."); + + if (intent.Status == UploadIntentStatus.Consumed) + return Result.Conflict("Upload intent has already been consumed."); + + if (intent.Status == UploadIntentStatus.Expired || intent.ExpiresAt < DateTime.UtcNow) + return Result.Invalid(new ValidationError("Upload intent has expired.")); + + var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); + if (metadata is null) + return Result.NotFound("Uploaded object not found in storage. Upload the file first."); + + if (metadata.ContentLength > config.MaxUploadBytes) + return Result.Invalid(new ValidationError($"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.")); + + return Result.Success(intent); + } +} diff --git a/MusifyBackend/Application/Shared/ImageSize.cs b/MusifyBackend/Application/Shared/ImageSize.cs new file mode 100644 index 0000000..c6f27d2 --- /dev/null +++ b/MusifyBackend/Application/Shared/ImageSize.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Shared +{ + public record ImageSize( + string SavePath, + int Width, + int Height); +} \ No newline at end of file diff --git a/MusifyBackend/Application/Shared/StorageKey.cs b/MusifyBackend/Application/Shared/StorageKey.cs new file mode 100644 index 0000000..82ddca8 --- /dev/null +++ b/MusifyBackend/Application/Shared/StorageKey.cs @@ -0,0 +1,12 @@ +namespace Musify.Application.Shared +{ + public static class StorageKey + { + public static string Combine(params string[] segments) + { + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + } + } +} diff --git a/MusifyBackend/Application/Tracks/Commands/CreateTrackCommand.cs b/MusifyBackend/Application/Tracks/Commands/CreateTrackCommand.cs new file mode 100644 index 0000000..b5fd8a3 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Commands/CreateTrackCommand.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Commands +{ + public record CreateTrackCommand( + long UserId, + string Title, + Guid PictureIntentId, + Guid AudioIntentId) + : ICommand>; +} diff --git a/MusifyBackend/Application/Tracks/Commands/DeleteTrackCommand.cs b/MusifyBackend/Application/Tracks/Commands/DeleteTrackCommand.cs new file mode 100644 index 0000000..619869e --- /dev/null +++ b/MusifyBackend/Application/Tracks/Commands/DeleteTrackCommand.cs @@ -0,0 +1,10 @@ +using Ardalis.Result; +using Mediator; + +namespace Musify.Application.Tracks.Commands +{ + public record DeleteTrackCommand( + long UserId, + Guid TrackId) + : ICommand; +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs b/MusifyBackend/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs new file mode 100644 index 0000000..ea82fbb --- /dev/null +++ b/MusifyBackend/Application/Tracks/Commands/RequestTrackUploadUrlsCommand.cs @@ -0,0 +1,16 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Commands +{ + public record RequestTrackUploadUrlsCommand( + long UserId, + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null) + : ICommand>; +} diff --git a/MusifyBackend/Application/Tracks/Handlers/CreateTrackCommandHandler.cs b/MusifyBackend/Application/Tracks/Handlers/CreateTrackCommandHandler.cs new file mode 100644 index 0000000..fcf4754 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/CreateTrackCommandHandler.cs @@ -0,0 +1,124 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Responses; +using Musify.Application.Extensions; +using Musify.Application.Services; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks.Handlers +{ + public class CreateTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users.AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.PictureIntentId, request.UserId, cancellationToken); + if (!pictureValidation.IsSuccess) + return pictureValidation.As(); + + var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.AudioIntentId, request.UserId, cancellationToken); + if (!audioValidation.IsSuccess) + return audioValidation.As(); + + var pictureIntent = pictureValidation.Value; + var audioIntent = audioValidation.Value; + + var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); + var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); + + var trackEntity = new Track + { + Title = request.Title, + NormalizedTitle = request.Title.ToUpperInvariant(), + OriginalPictureName = pictureIntent.ObjectName, + OriginalAudioName = audioIntent.ObjectName, + SmallPictureName = trackConfiguration.Routes.PresetSmallPicture, + MediumPictureName = trackConfiguration.Routes.PresetMediumPicture, + LargePictureName = trackConfiguration.Routes.PresetLargePicture, + PicturesProcessingStatus = ProcessingStatus.Pending, + AudioTranscodeProcessingStatus = ProcessingStatus.Pending + }; + + await database.Tracks.AddAsync(trackEntity, cancellationToken); + + await database.UserHasTracks.AddAsync(new UserHasTrack + { + UserId = request.UserId, + TrackId = trackEntity.Id + }, cancellationToken); + + try + { + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + new ImageSize( + trackConfiguration.Routes.SmallPicturesPath, + trackConfiguration.PicturesSizes.SmallPictureWidth, + trackConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + trackConfiguration.Routes.MediumPicturesPath, + trackConfiguration.PicturesSizes.MediumPictureWidth, + trackConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + trackConfiguration.Routes.LargePicturesPath, + trackConfiguration.PicturesSizes.LargePictureWidth, + trackConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id); + return Result.Error($"Failed to create track for {request.Title}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save track {Title}", request.Title); + return Result.Error($"Failed to create track for {request.Title}"); + } + + return Result.Created(TrackApplicationResponse.FromEntity(trackEntity)); + } + } +} diff --git a/MusifyBackend/Application/Tracks/Handlers/DeleteTrackCommandHandler.cs b/MusifyBackend/Application/Tracks/Handlers/DeleteTrackCommandHandler.cs new file mode 100644 index 0000000..a59e79b --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/DeleteTrackCommandHandler.cs @@ -0,0 +1,70 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Tracks.Commands; +using Musify.Domain.ValueObjects; +using Musify.Application.Events; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks.Handlers +{ + public class DeleteTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track is null) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Result.NotFound(); + } + + var isOwner = await database.UserHasTracks + .AsNoTracking() + .AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken); + if (!isOwner) + { + logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); + return Result.Unauthorized(); + } + + if (track.AudioTranscodeProcessingStatus == ProcessingStatus.Processing + || track.AudioTranscodeProcessingStatus == ProcessingStatus.Pending) + { + logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); + return Result.Conflict("Track is currently being processed and cannot be deleted"); + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + + try + { + await eventBus.PublishAsync( + new DeleteTrackEvent(track.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); + return Result.Error($"Failed to delete track {request.TrackId}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId); + return Result.Error($"Failed to delete track {request.TrackId}"); + } + + return Result.NoContent(); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs b/MusifyBackend/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs new file mode 100644 index 0000000..8fa199b --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/GetTrackByIdQueryHandler.cs @@ -0,0 +1,25 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Queries; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Handlers +{ + public class GetTrackByIdQueryHandler(IDatabase database) + : IQueryHandler > + { + public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => TrackApplicationResponse.FromEntity(t)) + .SingleOrDefaultAsync(cancellationToken); + + return track is null + ? Result.NotFound() + : Result.Success(track); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs b/MusifyBackend/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs new file mode 100644 index 0000000..b96925e --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/GetTrackStreamQueryHandler.cs @@ -0,0 +1,54 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Queries; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Handlers +{ + public class GetTrackStreamQueryHandler( + IDatabase database, + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + ILogger logger) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName }) + .SingleOrDefaultAsync(cancellationToken); + + if (track is null) + return Result.NotFound(); + + if (string.IsNullOrWhiteSpace(track.AudioFolderName) + || track.AudioTranscodeProcessingStatus != ProcessingStatus.Completed) + { + logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); + return Result.Conflict("Track audio is not available for streaming yet."); + } + + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName); + var keyPrefix = $"{folderPath}/"; + + var ticket = ticketService.IssueTicket(request.UserId, keyPrefix); + + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId); + + return Result.Success(new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds)); + } + } +} diff --git a/MusifyBackend/Application/Tracks/Handlers/GetTracksByUserIdQueryHandler.cs b/MusifyBackend/Application/Tracks/Handlers/GetTracksByUserIdQueryHandler.cs new file mode 100644 index 0000000..5822428 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/GetTracksByUserIdQueryHandler.cs @@ -0,0 +1,41 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Pagination; +using Musify.Application.Tracks.Queries; +using Musify.Application.Tracks.Responses; +using X.PagedList.EF; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks.Handlers +{ + public class GetTracksByUserIdQueryHandler(IDatabase database) + : IQueryHandler> > + { + public async ValueTask >> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + { + var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; + var pageSize = request.PageSize < 1 ? 10 : request.PageSize; + + var tracksQuery = database.UserHasTracks + .AsNoTracking() + .Include(ut => ut.Track) + .Where(p => p.UserId == request.UserId); + + if (request.Name is not null) + { + var normalizedName = request.Name.Trim().ToUpperInvariant(); + tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); + } + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedTracks = await tracksQuery + .OrderBy(t => t.Track.CreatedAt) + .Select(t => TrackApplicationResponse.FromEntity(t.Track)) + .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedTracks)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Handlers/GetTracksQueryHandler.cs b/MusifyBackend/Application/Tracks/Handlers/GetTracksQueryHandler.cs new file mode 100644 index 0000000..b19ebb6 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/GetTracksQueryHandler.cs @@ -0,0 +1,29 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Pagination; +using Musify.Application.Tracks.Queries; +using Musify.Application.Tracks.Responses; +using X.PagedList.EF; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks.Handlers +{ + public class GetTracksQueryHandler(IDatabase database) + : IQueryHandler> > + { + public async ValueTask >> Handle(GetTracksQuery request, CancellationToken cancellationToken) + { + var tracksQuery = database.UserHasTracks + .AsNoTracking() + .OrderBy(t => t.Id) + .Include(ut => ut.Track) + .Select(t => TrackApplicationResponse.FromEntity(t.Track)); + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + var pagedTracks = await tracksQuery.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedTracks)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Handlers/RequestTrackUploadUrlsCommandHandler.cs b/MusifyBackend/Application/Tracks/Handlers/RequestTrackUploadUrlsCommandHandler.cs new file mode 100644 index 0000000..c651282 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Handlers/RequestTrackUploadUrlsCommandHandler.cs @@ -0,0 +1,134 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Services; +using Musify.Application.Tracks.Commands; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks.Handlers +{ + public class RequestTrackUploadUrlsCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + { + logger.LogWarning("User {UserId} not found", request.UserId); + return Result.NotFound($"User {request.UserId} not found"); + } + + var effectivePictureSize = request.ExpectedPictureSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + var effectiveAudioSize = request.ExpectedAudioSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes; + + var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; + var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; + + var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName); + var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName); + + try + { + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + + var pictureUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempPictureKey, + request.PictureContentType, + expiresIn, + cancellationToken); + + var audioUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempAudioKey, + request.AudioContentType, + expiresIn, + cancellationToken); + + var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken); + if (!quotaCheck.IsSuccess) + { + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck; + } + + var pictureIntent = new UploadIntent + { + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempPictureKey, + ObjectName = pictureObjectName, + ContentType = request.PictureContentType, + ExpectedSizeBytes = effectivePictureSize, + Purpose = UploadIntentPurpose.TrackPicture, + ExpiresAt = expiresAt, + }; + + var audioIntent = new UploadIntent + { + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempAudioKey, + ObjectName = audioObjectName, + ContentType = request.AudioContentType, + ExpectedSizeBytes = effectiveAudioSize, + Purpose = UploadIntentPurpose.TrackAudio, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(pictureIntent, cancellationToken); + await database.UploadIntents.AddAsync(audioIntent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)", + pictureIntent.Id, audioIntent.Id, request.UserId); + + return Result.Success(new TrackUploadUrlsResponse( + pictureIntent.Id, + audioIntent.Id, + storageConfiguration.Bucket, + tempPictureKey, + pictureObjectName, + request.PictureContentType, + pictureUploadUrl, + tempAudioKey, + audioObjectName, + request.AudioContentType, + audioUploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); + return Result.Error("Failed to generate upload URLs"); + } + } + } +} diff --git a/MusifyBackend/Application/Tracks/Queries/GetTrackByIdQuery.cs b/MusifyBackend/Application/Tracks/Queries/GetTrackByIdQuery.cs new file mode 100644 index 0000000..cdc3709 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Queries/GetTrackByIdQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Queries +{ + public record GetTrackByIdQuery(Guid TrackId) + : IQuery>; +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Queries/GetTrackStreamQuery.cs b/MusifyBackend/Application/Tracks/Queries/GetTrackStreamQuery.cs new file mode 100644 index 0000000..03286ae --- /dev/null +++ b/MusifyBackend/Application/Tracks/Queries/GetTrackStreamQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Queries +{ + public record GetTrackStreamQuery(Guid TrackId, long UserId) + : IQuery>; +} diff --git a/MusifyBackend/Application/Tracks/Queries/GetTracksByUserIdQuery.cs b/MusifyBackend/Application/Tracks/Queries/GetTracksByUserIdQuery.cs new file mode 100644 index 0000000..6e389dd --- /dev/null +++ b/MusifyBackend/Application/Tracks/Queries/GetTracksByUserIdQuery.cs @@ -0,0 +1,14 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Queries +{ + public record GetTracksByUserIdQuery( + long UserId, + string? Name = null, + int PageNumber = 1, + int PageSize = 10) + : IQuery > >; +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Queries/GetTracksQuery.cs b/MusifyBackend/Application/Tracks/Queries/GetTracksQuery.cs new file mode 100644 index 0000000..b483e56 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Queries/GetTracksQuery.cs @@ -0,0 +1,10 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.Tracks.Responses; + +namespace Musify.Application.Tracks.Queries +{ + public record GetTracksQuery(int PageNumber = 1, int PageSize = 10) + : IQuery >>; +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Responses/TrackApplicationResponse.cs b/MusifyBackend/Application/Tracks/Responses/TrackApplicationResponse.cs new file mode 100644 index 0000000..83d3fa7 --- /dev/null +++ b/MusifyBackend/Application/Tracks/Responses/TrackApplicationResponse.cs @@ -0,0 +1,20 @@ +using Musify.Domain.Entities; + +namespace Musify.Application.Tracks.Responses +{ + public record TrackApplicationResponse( + Guid Id, + string Title, + DateTime CreatedAt, + DateTime UpdatedAt) + { + public static TrackApplicationResponse FromEntity(Track track) + { + return new TrackApplicationResponse( + track.Id, + track.Title, + track.CreatedAt, + track.UpdatedAt); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Tracks/Responses/TrackStreamResponse.cs b/MusifyBackend/Application/Tracks/Responses/TrackStreamResponse.cs new file mode 100644 index 0000000..c925a8b --- /dev/null +++ b/MusifyBackend/Application/Tracks/Responses/TrackStreamResponse.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackStreamResponse( + string ManifestUrl, + string Ticket, + int ExpiresInSeconds); +} diff --git a/MusifyBackend/Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/MusifyBackend/Application/Tracks/Responses/TrackUploadUrlsResponse.cs new file mode 100644 index 0000000..8e1f5fb --- /dev/null +++ b/MusifyBackend/Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -0,0 +1,16 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackUploadUrlsResponse( + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureKey, + string PictureName, + string PictureContentType, + string PictureUploadUrl, + string AudioKey, + string AudioName, + string AudioContentType, + string AudioUploadUrl, + int ExpiresInSeconds); +} diff --git a/MusifyBackend/Application/Users/Commands/CreateUserCommand.cs b/MusifyBackend/Application/Users/Commands/CreateUserCommand.cs new file mode 100644 index 0000000..6415e31 --- /dev/null +++ b/MusifyBackend/Application/Users/Commands/CreateUserCommand.cs @@ -0,0 +1,26 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Users.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Users.Commands +{ + public record CreateUserCommand(long Id, + string Name, + string? FirstName, + string? SecondName) + : ICommand> + { + public static User ToEntity(CreateUserCommand command) + { + return new User + { + Id = command.Id, + Name = command.Name, + NormalizedName = command.Name.ToUpperInvariant(), + FirstName = command.FirstName, + SecondName = command.SecondName + }; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Users/Commands/SyncUserCommand.cs b/MusifyBackend/Application/Users/Commands/SyncUserCommand.cs new file mode 100644 index 0000000..5419614 --- /dev/null +++ b/MusifyBackend/Application/Users/Commands/SyncUserCommand.cs @@ -0,0 +1,13 @@ +using Ardalis.Result; +using Mediator; + +namespace Musify.Application.Users.Commands +{ + public record SyncUserCommand( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl) + : ICommand; +} diff --git a/MusifyBackend/Application/Users/Handlers/CreateUserCommandHandler.cs b/MusifyBackend/Application/Users/Handlers/CreateUserCommandHandler.cs new file mode 100644 index 0000000..fabcd17 --- /dev/null +++ b/MusifyBackend/Application/Users/Handlers/CreateUserCommandHandler.cs @@ -0,0 +1,40 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Users.Commands; +using Musify.Application.Users.Responses; + +namespace Musify.Application.Users.Handlers +{ + public class CreateUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler > + { + public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) + { + var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken); + if (userExists) + { + logger.LogInformation("User {UserId} already exists", request.Id); + return Result.Conflict($"User {request.Id} already exists"); + } + + var newUser = CreateUserCommand.ToEntity(request); + + try + { + await database.Users.AddAsync(newUser, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to create user {UserId}", request.Id); + return Result.Error($"Failed to create user {request.Id}"); + } + + logger.LogInformation("Created user {UserId}", newUser.Id); + return Result.Created(UserApplicationResponse.FromEntity(newUser)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Users/Handlers/GetUserByIdQueryHandler.cs b/MusifyBackend/Application/Users/Handlers/GetUserByIdQueryHandler.cs new file mode 100644 index 0000000..c99c799 --- /dev/null +++ b/MusifyBackend/Application/Users/Handlers/GetUserByIdQueryHandler.cs @@ -0,0 +1,26 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Users.Queries; +using Musify.Application.Users.Responses; + +namespace Musify.Application.Users.Handlers +{ + public class GetUserByIdQueryHandler(IDatabase database) + : IQueryHandler > + { + public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.Id) + .Select(u => UserApplicationResponse.FromEntity(u)) + .SingleOrDefaultAsync(cancellationToken); + + return user is null + ? Result.NotFound() + : Result.Success(user); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Users/Handlers/GetUsersQueryHandler.cs b/MusifyBackend/Application/Users/Handlers/GetUsersQueryHandler.cs new file mode 100644 index 0000000..cca1b12 --- /dev/null +++ b/MusifyBackend/Application/Users/Handlers/GetUsersQueryHandler.cs @@ -0,0 +1,35 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Pagination; +using Musify.Application.Users.Queries; +using Musify.Application.Users.Responses; +using X.PagedList.EF; +using Musify.Application.Contracts; + +namespace Musify.Application.Users.Handlers +{ + public class GetUsersQueryHandler(IDatabase database) + : IQueryHandler > > + { + public async ValueTask >> Handle(GetUsersQuery request, CancellationToken cancellationToken) + { + var user = database.Users + .AsNoTracking() + .AsQueryable(); + + if (request.UsernameSearch is not null) + { + var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); + user = user.Where(u => u.NormalizedName.Contains(normalizedUsername)); + } + + var totalCount = await user.CountAsync(cancellationToken); + var pagedUser = await user + .Select(u => UserApplicationResponse.FromEntity(u)) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return Result.Success(PaginatedResponse.FromPagedList(pagedUser)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Application/Users/Handlers/SyncUserCommandHandler.cs b/MusifyBackend/Application/Users/Handlers/SyncUserCommandHandler.cs new file mode 100644 index 0000000..61aabb8 --- /dev/null +++ b/MusifyBackend/Application/Users/Handlers/SyncUserCommandHandler.cs @@ -0,0 +1,50 @@ +using Ardalis.Result; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Users.Commands; +using Musify.Domain.Entities; + +namespace Musify.Application.Users.Handlers +{ + public class SyncUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler + { + public async ValueTask Handle(SyncUserCommand request, CancellationToken cancellationToken) + { + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + + if (user is null) + { + await database.Users.AddAsync(new User + { + Id = request.Id, + Name = request.Name, + NormalizedName = request.Name.ToUpperInvariant(), + FirstName = request.FirstName, + SecondName = request.SecondName, + ProfilePictureUrl = request.ProfilePictureUrl + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Provisioned user {UserId}", request.Id); + + return Result.Success(); + } + + user.Name = request.Name; + user.NormalizedName = request.Name.ToUpperInvariant(); + user.FirstName = request.FirstName; + user.SecondName = request.SecondName; + user.ProfilePictureUrl = request.ProfilePictureUrl; + + var written = await database.SaveChangesAsync(cancellationToken); + if (written > 0) + logger.LogInformation("Synced profile for user {UserId}", request.Id); + + return Result.Success(); + } + } +} diff --git a/MusifyBackend/Application/Users/Queries/GetUserByIdQuery.cs b/MusifyBackend/Application/Users/Queries/GetUserByIdQuery.cs new file mode 100644 index 0000000..f17c5e6 --- /dev/null +++ b/MusifyBackend/Application/Users/Queries/GetUserByIdQuery.cs @@ -0,0 +1,9 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Users.Responses; + +namespace Musify.Application.Users.Queries +{ + public record GetUserByIdQuery(long Id) + : IQuery >; +} \ No newline at end of file diff --git a/MusifyBackend/Application/Users/Queries/GetUsersQuery.cs b/MusifyBackend/Application/Users/Queries/GetUsersQuery.cs new file mode 100644 index 0000000..edc2e7b --- /dev/null +++ b/MusifyBackend/Application/Users/Queries/GetUsersQuery.cs @@ -0,0 +1,10 @@ +using Ardalis.Result; +using Mediator; +using Musify.Application.Pagination; +using Musify.Application.Users.Responses; + +namespace Musify.Application.Users.Queries +{ + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch = null) + : IQuery >>; +} diff --git a/MusifyBackend/Application/Users/Responses/UserApplicationResponse.cs b/MusifyBackend/Application/Users/Responses/UserApplicationResponse.cs new file mode 100644 index 0000000..bde3649 --- /dev/null +++ b/MusifyBackend/Application/Users/Responses/UserApplicationResponse.cs @@ -0,0 +1,26 @@ +using Musify.Domain.Entities; + +namespace Musify.Application.Users.Responses +{ + public record UserApplicationResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + DateTime UpdatedAt) + { + public static UserApplicationResponse FromEntity(User user) + { + return new UserApplicationResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + user.UpdatedAt); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Abstractions/IAuditable.cs b/MusifyBackend/Domain/Abstractions/IAuditable.cs new file mode 100644 index 0000000..4815e2c --- /dev/null +++ b/MusifyBackend/Domain/Abstractions/IAuditable.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.Abstractions +{ + public interface IAuditable + { + DateTime CreatedAt { get; set; } + + DateTime UpdatedAt { get; set; } + } +} diff --git a/MusifyBackend/Domain/Domain.csproj b/MusifyBackend/Domain/Domain.csproj new file mode 100644 index 0000000..6338edf --- /dev/null +++ b/MusifyBackend/Domain/Domain.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + Musify.Domain + Musify.Domain + 0.1.2 + NotS1mp + https://github.com/XShine555/MusifyBackend + + + + diff --git a/MusifyBackend/Domain/Entities/PlayList.cs b/MusifyBackend/Domain/Entities/PlayList.cs new file mode 100644 index 0000000..cf3d509 --- /dev/null +++ b/MusifyBackend/Domain/Entities/PlayList.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("PlayLists")] + public class PlayList : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + [MaxLength(50)] + public required string Name { get; set; } + + [Required] + [MaxLength(50)] + public required string NormalizedName { get; set; } + + [Required] + [MaxLength(256)] + public required string Description { get; set; } = "No description was provided."; + + [Required] + [MaxLength(64)] + public required string OriginalPictureName { get; set; } + + [Required] + [MaxLength(64)] + public required string SmallPictureName { get; set; } + + [Required] + [MaxLength(64)] + public required string MediumPictureName { get; set; } + + [Required] + [MaxLength(64)] + public required string LargePictureName { get; set; } + + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId)) ] + public User User { get; set; } + + public ICollection PlayListTracks { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Entities/PlayListHasTrack.cs b/MusifyBackend/Domain/Entities/PlayListHasTrack.cs new file mode 100644 index 0000000..3de229f --- /dev/null +++ b/MusifyBackend/Domain/Entities/PlayListHasTrack.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("PlayListHasTrack")] + public class PlayListHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required Guid PlayListId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [Required] + public int Position { get; set; } = 0; + + [Required] + public DateTime AddedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(PlayListId)) ] + public PlayList PlayList { get; set; } + + [ForeignKey(nameof(TrackId)) ] + public Track Track { get; set; } + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Entities/Track.cs b/MusifyBackend/Domain/Entities/Track.cs new file mode 100644 index 0000000..8299c3d --- /dev/null +++ b/MusifyBackend/Domain/Entities/Track.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics.CodeAnalysis; + +using Musify.Domain.Abstractions; +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + public class Track : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(50)] + public required string Title { get; set; } + + [Required] + [MaxLength(50)] + public required string NormalizedTitle { get; set; } + + [Required] + [MaxLength(64)] + public required string OriginalPictureName { get; set; } + + [MaxLength(64)] + public string? SmallPictureName { get; set; } + + [MaxLength(64)] + public string? MediumPictureName { get; set; } + + [MaxLength(64)] + public string? LargePictureName { get; set; } + + [Required] + [MaxLength(64)] + public required string OriginalAudioName { get; set; } + + [MaxLength(64)] + public string? AudioFolderName { get; set; } + + [Required] + public ProcessingStatus PicturesProcessingStatus { get; set; } = ProcessingStatus.Pending; + + [Required] + public ProcessingStatus AudioTranscodeProcessingStatus { get; set; } = ProcessingStatus.Pending; + + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + + [Required] + public int RetryCount { get; set; } = 0; + + public DateTime? LastRetryAt { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection UserTracks { get; set; } = new List(); + + public ICollection PlayListTracks { get; set; } = new List(); + + [NotMapped] + [MemberNotNullWhen(true, nameof(SmallPictureName)) ] + [MemberNotNullWhen(true, nameof(MediumPictureName)) ] + [MemberNotNullWhen(true, nameof(LargePictureName)) ] + public bool IsPicturesProcessed => PicturesProcessingStatus == ProcessingStatus.Completed; + + [NotMapped] + [MemberNotNullWhen(true, nameof(AudioFolderName)) ] + public bool IsAudioProcessed => AudioTranscodeProcessingStatus == ProcessingStatus.Completed; + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Entities/Upload.cs b/MusifyBackend/Domain/Entities/Upload.cs new file mode 100644 index 0000000..1cb9cb7 --- /dev/null +++ b/MusifyBackend/Domain/Entities/Upload.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ + [Table("Upload")] + public class Upload + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required string Bucket { get; set; } + + [Required] + public required string Key { get; set; } + + [Required] + public required string ContentType { get; set; } + + [Required] + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + + [Required] + public UploadState State { get; set; } = UploadState.Pending; + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Entities/UploadIntent.cs b/MusifyBackend/Domain/Entities/UploadIntent.cs new file mode 100644 index 0000000..4f1d15e --- /dev/null +++ b/MusifyBackend/Domain/Entities/UploadIntent.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +using Musify.Domain.ValueObjects; + +namespace Musify.Domain.Entities +{ + [Table("UploadIntents")] + public class UploadIntent + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required, MaxLength(128)] + public required string Bucket { get; set; } + + [Required, MaxLength(512)] + public required string Key { get; set; } + + [Required, MaxLength(128)] + public required string ObjectName { get; set; } + + [Required, MaxLength(128)] + public required string ContentType { get; set; } + + public long? ExpectedSizeBytes { get; set; } + + [Required] + public UploadIntentPurpose Purpose { get; set; } + + [Required] + public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; + + [Required] + public DateTime ExpiresAt { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [ForeignKey(nameof(UserId)) ] + public User User { get; set; } = null!; + } +} diff --git a/MusifyBackend/Domain/Entities/User.cs b/MusifyBackend/Domain/Entities/User.cs new file mode 100644 index 0000000..184dbc6 --- /dev/null +++ b/MusifyBackend/Domain/Entities/User.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Musify.Domain.Abstractions; + +namespace Musify.Domain.Entities +{ + [Table("User")] + public class User : IAuditable + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public required long Id { get; set; } + + [Required] + [MaxLength(48)] + public required string Name { get; set; } + + [Required] + [MaxLength(48)] + public required string NormalizedName { get; set; } + + [MaxLength(48)] + public string? FirstName { get; set; } + + [MaxLength(48)] + public string? SecondName { get; set; } + + [MaxLength(2048)] + public string? ProfilePictureUrl { get; set; } + + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection UserTracks { get; set; } = new List(); + + public ICollection PlayLists { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/Entities/UserHasTrack.cs b/MusifyBackend/Domain/Entities/UserHasTrack.cs new file mode 100644 index 0000000..f2e72b3 --- /dev/null +++ b/MusifyBackend/Domain/Entities/UserHasTrack.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Musify.Domain.Entities +{ +#pragma warning disable CS8618 + [Table("UserHasTrack")] + public class UserHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public required long UserId { get; set; } + + [Required] + public required Guid TrackId { get; set; } + + [ForeignKey(nameof(UserId)) ] + public User User { get; set; } + + [ForeignKey(nameof(TrackId)) ] + public Track Track { get; set; } + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/ValueObjects/LifeCycleStatus.cs b/MusifyBackend/Domain/ValueObjects/LifeCycleStatus.cs new file mode 100644 index 0000000..bed197e --- /dev/null +++ b/MusifyBackend/Domain/ValueObjects/LifeCycleStatus.cs @@ -0,0 +1,8 @@ +namespace Musify.Domain.ValueObjects +{ + public enum LifeCycleStatus + { + Active = 0, + Removing = 1 + } +} diff --git a/MusifyBackend/Domain/ValueObjects/ProcessingStatus.cs b/MusifyBackend/Domain/ValueObjects/ProcessingStatus.cs new file mode 100644 index 0000000..66aa32e --- /dev/null +++ b/MusifyBackend/Domain/ValueObjects/ProcessingStatus.cs @@ -0,0 +1,10 @@ +namespace Musify.Domain.ValueObjects +{ + public enum ProcessingStatus + { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3 + } +} \ No newline at end of file diff --git a/MusifyBackend/Domain/ValueObjects/UploadIntentPurpose.cs b/MusifyBackend/Domain/ValueObjects/UploadIntentPurpose.cs new file mode 100644 index 0000000..dc0339b --- /dev/null +++ b/MusifyBackend/Domain/ValueObjects/UploadIntentPurpose.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.ValueObjects +{ + public enum UploadIntentPurpose + { + PlayListPicture, + TrackPicture, + TrackAudio, + } +} diff --git a/MusifyBackend/Domain/ValueObjects/UploadIntentStatus.cs b/MusifyBackend/Domain/ValueObjects/UploadIntentStatus.cs new file mode 100644 index 0000000..7a6e23f --- /dev/null +++ b/MusifyBackend/Domain/ValueObjects/UploadIntentStatus.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.ValueObjects +{ + public enum UploadIntentStatus + { + Issued, + Consumed, + Expired, + } +} diff --git a/MusifyBackend/Domain/ValueObjects/UploadState.cs b/MusifyBackend/Domain/ValueObjects/UploadState.cs new file mode 100644 index 0000000..5bcaf5b --- /dev/null +++ b/MusifyBackend/Domain/ValueObjects/UploadState.cs @@ -0,0 +1,9 @@ +namespace Musify.Domain.ValueObjects +{ + public enum UploadState + { + Pending, + Failed, + Successful, + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Configuration/AudioTranscoderConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/AudioTranscoderConfiguration.cs new file mode 100644 index 0000000..7bf532c --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/AudioTranscoderConfiguration.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class AudioTranscoderConfiguration + { + public const string SectionName = "AudioTranscoder"; + + [Required] + public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); + + [Required] + public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration(); + } + + public class FfmpegConfiguration + { + [Required] + public string ExecutableName { get; set; } = "ffmpeg"; + + [Required] + public string OutputFileName { get; set; } = "audio.m4a"; + + [Required] + public string AudioCodec { get; set; } = "aac"; + + [Required] + public string AudioBitrate { get; set; } = "128k"; + + [Range(1, 8)] + public int AudioChannels { get; set; } = 2; + + [Range(8000, 192000)] + public int AudioSampleRate { get; set; } = 48000; + + [Required] + public string AudioProfile { get; set; } = "aac_low"; + + public string AdditionalArguments { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Configuration/DatabaseConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/DatabaseConfiguration.cs new file mode 100644 index 0000000..e79f5ed --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/DatabaseConfiguration.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class DatabaseConfiguration + { + public const string SectionName = "Database"; + + [Required] + public required string ConnectionString { get; set; } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/InfrastructureStorageConfiguration.cs new file mode 100644 index 0000000..2c2afb7 --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class InfrastructureStorageConfiguration + { + public const string SectionName = "InfrastructureStorage"; + + [Url] + [Required] + public required string Address { get; set; } + + [Required] + public required string AccessKey { get; set; } + + [Required] + public required string SecretAccessKey { get; set; } + + [Required] + public bool ForcePathStyle { get; set; } + + [Required] + public bool UseHttp { get; set; } + } +} diff --git a/MusifyBackend/Infrastructure/Configuration/MassTransitConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/MassTransitConfiguration.cs new file mode 100644 index 0000000..df216ee --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/MassTransitConfiguration.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class MassTransitConfiguration + { + public const string SectionName = "MassTransit"; + + [Required] + public required string Address { get; set; } + + [Required] + public required string Username { get; set; } + + [Required] + public required string Password { get; set; } + } +} diff --git a/MusifyBackend/Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/OpenTelemetryConfiguration.cs new file mode 100644 index 0000000..e435db1 --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class OpenTelemetryConfiguration + { + public const string SectionName = "OpenTelemetry"; + + [Url] + [Required] + public required string OtlpEndpoint { get; set; } + } +} diff --git a/MusifyBackend/Infrastructure/Configuration/StreamTicketConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/StreamTicketConfiguration.cs new file mode 100644 index 0000000..0773c90 --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class StreamTicketConfiguration + { + public const string SectionName = "StreamTicket"; + + [Required] + public required string PrivateKeyPath { get; set; } + + [Required] + public string Audience { get; set; } = "media-gateway"; + + [Required] + public string Issuer { get; set; } = "musify-webapi"; + + [Range(60, 86400)] + public int TicketTtlSeconds { get; set; } = 3600; + } +} diff --git a/MusifyBackend/Infrastructure/Configuration/WorkerConfiguration.cs b/MusifyBackend/Infrastructure/Configuration/WorkerConfiguration.cs new file mode 100644 index 0000000..fa33cae --- /dev/null +++ b/MusifyBackend/Infrastructure/Configuration/WorkerConfiguration.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class WorkerConfiguration + { + public const string SectionName = "Workers"; + + [Required] + public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); + } + + public class WorkerRoutes + { + [Required] + public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath(); + } +} diff --git a/MusifyBackend/Infrastructure/DesignSettings.json b/MusifyBackend/Infrastructure/DesignSettings.json new file mode 100644 index 0000000..afb3092 --- /dev/null +++ b/MusifyBackend/Infrastructure/DesignSettings.json @@ -0,0 +1,5 @@ +{ + "Database": { + "ConnectionString": "" + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Infrastructure.csproj b/MusifyBackend/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..b64cac2 --- /dev/null +++ b/MusifyBackend/Infrastructure/Infrastructure.csproj @@ -0,0 +1,51 @@ + + + + net10.0 + enable + enable + Musify.Infrastructure + 07d419fc-9ade-471a-bc0e-aebece785b30 + Musify.Infrastructure + Musify.Infrastructure + 0.1.1 + NotS1mp + https://github.com/XShine555/MusifyBackend + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MusifyBackend/Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/MusifyBackend/Infrastructure/Jobs/TemporalUploadsCleanupJob.cs new file mode 100644 index 0000000..ac20eb7 --- /dev/null +++ b/MusifyBackend/Infrastructure/Jobs/TemporalUploadsCleanupJob.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Jobs +{ + public class TemporalUploadsCleanUpJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", + uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, + uploadIntentConfiguration.TemporalRootPrefix); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); + } + + await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds), stoppingToken); + } + } + + async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays); + var deleted = 0; + var errors = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, + uploadIntentConfiguration.TemporalRootPrefix, + cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + try + { + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + catch (Exception exception) + { + errors++; + logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); + } + } + + if (deleted > 0 || errors > 0) + logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); + } + } +} diff --git a/MusifyBackend/Infrastructure/Jobs/UploadIntentExpirationJob.cs b/MusifyBackend/Infrastructure/Jobs/UploadIntentExpirationJob.cs new file mode 100644 index 0000000..6e0cc78 --- /dev/null +++ b/MusifyBackend/Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Jobs +{ + public class UploadIntentExpirationJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RunAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in UploadIntentExpirationJob"); + } + + await Task.Delay(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds), stoppingToken); + } + } + + async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + + var expiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) + .ToListAsync(cancellationToken); + + if (expiredIntents.Count > 0) + { + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); + } + + if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0) + { + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + + var oldExpiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); + + if (oldExpiredIntents.Count > 0) + { + database.UploadIntents.RemoveRange(oldExpiredIntents); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); + } + } + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs new file mode 100644 index 0000000..b404528 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -0,0 +1,73 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + internal class GenerateAudioWorkflowPathsActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "generate-audio-workflow-paths"; + + public async Task Execute(ExecuteContext executeContext) + { + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found."); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}", + executeContext.Arguments.TrackId); + throw; + } + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Processing; + var folderName = Guid.NewGuid().ToString(); + var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); + var sourceFilePath = Path.Combine(workingDirectory, sourceFileName); + + Directory.CreateDirectory(workingDirectory); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); + + return executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory + } ); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}", + executeContext.Arguments.SourceKey); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + + throw; + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs new file mode 100644 index 0000000..36f3f68 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -0,0 +1,89 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + internal class TranscodeAudioActivity( + IDatabase database, + IAudioTranscoderService audioTranscoder, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "transcode-audio"; + + public async Task Execute(ExecuteContext executeContext) + { + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); + + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); + throw; + } + + try + { + await using (var fileStream = File.OpenRead(sourceFilePath)) + { + await audioTranscoder.TranscodeToAudioFileAsync( + fileStream, + workingDirectory, + executeContext.CancellationToken); + } + + File.Delete(sourceFilePath); + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + + throw; + } + } + + public Task Compensate(CompensateContext compensateContext) + { + try + { + if (Directory.Exists(compensateContext.Log.WorkingDirectory)) + Directory.Delete(compensateContext.Log.WorkingDirectory, recursive: true); + else + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", + compensateContext.Log.WorkingDirectory); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate transcoded audio directory {WorkingDirectory}", compensateContext.Log.WorkingDirectory); + return Task.FromResult(compensateContext.Failed(exception)); + } + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs new file mode 100644 index 0000000..befa597 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -0,0 +1,101 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Audio +{ + internal class UpdateTrackAudioActivity( + IDatabase database, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "update-track-audio"; + + public async Task Execute(ExecuteContext executeContext) + { + Track track; + try + { + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track {TrackId}", + executeContext.Arguments.TrackId); + throw; + } + + var audioFolderKey = executeContext.Arguments.AudioFolderKey; + logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey); + + if (string.IsNullOrWhiteSpace(audioFolderKey)) + { + logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id); + return executeContext.Completed(); + } + + try + { + var log = new UpdateTrackAudioLog( + track.Id, + track.AudioFolderName); + + track.AudioFolderName = Path.GetFileName(audioFolderKey); + track.AudioTranscodeProcessingStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); + + return executeContext.Completed(log); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update track {TrackId} audio", + executeContext.Arguments.TrackId); + + try + { + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + } + catch (Exception dbException) + { + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); + } + + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); + if (track is null) + throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); + + track.AudioFolderName = compensateContext.Log.PreviousAudioFolderName; + track.AudioTranscodeProcessingStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate track {TrackId} audio", + compensateContext.Log.TrackId); + return compensateContext.Failed(exception); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs new file mode 100644 index 0000000..22e061a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -0,0 +1,61 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + internal class CopyFileInBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "copy-file-in-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + try + { + await storageHandler.CopyFileAsync( + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey, + executeContext.CancellationToken); + + return executeContext.Completed(new CopyFileInBucketLog( + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to copy {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + await storageHandler.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs new file mode 100644 index 0000000..53803cf --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -0,0 +1,63 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + internal class DownloadFileFromBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "download-file-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + + try + { + using var fileStream = await storageHandler.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + if (fileStream.CanSeek) + fileStream.Position = 0; + + using var destinationStream = File.Create(destinationPath); + await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); + return executeContext.Completed(new DownloadFileFromBucketLog( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + destinationPath)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to download file from bucket"); + throw; + } + } + + public Task Compensate(CompensateContext compensateContext) + { + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs new file mode 100644 index 0000000..b148c2b --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -0,0 +1,46 @@ +using MassTransit; +using Amazon.S3; +using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.Arguments; +using System.Net; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + internal class RemoveFileFromBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "remove-file-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + try + { + await storageHandler.RemoveFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken); + return executeContext.Completed(); + } + catch (AmazonS3Exception exception) when ( + exception.StatusCode == HttpStatusCode.NotFound + || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Key {Key} not found in bucket {Bucket}, skipping", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + throw; + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs new file mode 100644 index 0000000..8e1db84 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -0,0 +1,66 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + public class TransferFilesToBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "transfer-files-to-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); + ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath)); + var destinationKey = executeContext.Arguments.DestinationKey; + ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey)); + + try + { + var uploadedKeys = Directory.GetFiles(folderPath) + .Select(file => Path.Combine(destinationKey, Path.GetFileName(file))) + .ToArray(); + + await storageHandler.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to transfer files from {FolderPath} to {DestinationBucket}", + folderPath, + executeContext.Arguments.DestinationBucket); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + foreach (var uploadedKey in compensateContext.Log.UploadedKeys) + { + await storageHandler.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + uploadedKey, + compensateContext.CancellationToken); + } + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); + return compensateContext.Failed(exception); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs new file mode 100644 index 0000000..62961bc --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -0,0 +1,61 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.Files +{ + public class UploadFileToBucketActivity(IStorageService storageHandler, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "upload-file-to-bucket"; + + public async Task Execute(ExecuteContext executeContext) + { + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + var fileName = Path.GetFileName(sourceFilePath); + var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + + try + { + await storageHandler.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to upload file to bucket"); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + await storageHandler.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs new file mode 100644 index 0000000..f7a0c83 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + public class GeneratePictureWorkflowPathsActivity( + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "generate-picture-workflow-paths"; + + internal const string ResizedPictureFileExtension = ".webp"; + + public async Task Execute(ExecuteContext executeContext) + { + try + { + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + + var sourceFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); + var smallPictureFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var mediumPictureFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var largePictureFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + + Directory.CreateDirectory(workingDirectory); + + logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", + workingDirectory); + return executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, + [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, + [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath + } ); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate picture workflow paths for {SourceKey}", + executeContext.Arguments.SourceKey); + throw; + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs new file mode 100644 index 0000000..193b100 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -0,0 +1,72 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + public class ResizePictureActivity( + IPictureService pictureHandler, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "resize-picture"; + + public async Task Execute(ExecuteContext executeContext) + { + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath)); + + try + { + if (!File.Exists(sourceFilePath)) + { + logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); + throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); + } + + await using var fileStream = File.OpenRead(sourceFilePath); + + var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( + fileStream, + executeContext.Arguments.Width, + executeContext.Arguments.Height, + executeContext.CancellationToken); + + var destinationDirectory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrEmpty(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using var destinationStream = File.Create(destinationFilePath); + await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); + + logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", + sourceFilePath, + destinationFilePath); + return executeContext.Completed(new ResizePictureLog(destinationFilePath)); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to resize picture {SourceFilePath}", sourceFilePath); + throw; + } + } + + public Task Compensate(CompensateContext compensateContext) + { + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs new file mode 100644 index 0000000..63dd568 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -0,0 +1,95 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + public class UpdatePlayListPictureActivity( + IDatabase database, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "update-playlist-picture"; + + public async Task Execute(ExecuteContext executeContext) + { + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + + try + { + var playList = await database.PlayLists.FindAsync( + [executeContext.Arguments.PlayListId], + cancellationToken: executeContext.CancellationToken); + + if (playList is null) + { + logger.LogWarning("Playlist {PlayListId} not found", + executeContext.Arguments.PlayListId); + throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); + } + + var log = new UpdatePlayListPictureLog( + playList.Id, + playList.OriginalPictureName, + playList.SmallPictureName, + playList.MediumPictureName, + playList.LargePictureName); + + playList.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + playList.SmallPictureName = Path.GetFileName(smallResizedVariable); + playList.MediumPictureName = Path.GetFileName(mediumResizedVariable); + playList.LargePictureName = Path.GetFileName(largeResizedVariable); + + database.PlayLists.Update(playList); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated playlist {PlayListId} pictures", + executeContext.Arguments.PlayListId); + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update playlist {PlayListId} pictures", + executeContext.Arguments.PlayListId); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var playList = await database.PlayLists.FindAsync( + [compensateContext.Log.PlayListId], + cancellationToken: compensateContext.CancellationToken); + + if (playList is null) + { + return compensateContext.Compensated(); + } + + playList.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey; + playList.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey; + playList.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey; + playList.LargePictureName = compensateContext.Log.PreviousLargePictureKey; + + database.PlayLists.Update(playList); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate playlist {PlayListId} pictures", compensateContext.Log.PlayListId); + return compensateContext.Failed(exception); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs new file mode 100644 index 0000000..7b16eaf --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs @@ -0,0 +1,97 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities +{ + public class UpdateTrackPictureActivity( + IDatabase database, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "update-track-picture"; + + public async Task Execute(ExecuteContext executeContext) + { + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + + try + { + var track = await database.Tracks.FindAsync( + [executeContext.Arguments.TrackId], + cancellationToken: executeContext.CancellationToken); + + if (track is null) + { + logger.LogWarning("Track {TrackId} not found", + executeContext.Arguments.TrackId); + throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); + } + + var log = new UpdateTrackPictureLog( + track.Id, + track.OriginalPictureName, + track.SmallPictureName, + track.MediumPictureName, + track.LargePictureName); + + track.OriginalPictureName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + track.SmallPictureName = Path.GetFileName(smallResizedVariable); + track.MediumPictureName = Path.GetFileName(mediumResizedVariable); + track.LargePictureName = Path.GetFileName(largeResizedVariable); + track.PicturesProcessingStatus = ProcessingStatus.Completed; + + database.Tracks.Update(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated track {TrackId} pictures", + executeContext.Arguments.TrackId); + + return executeContext.Completed(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update track {TrackId} pictures", + executeContext.Arguments.TrackId); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var track = await database.Tracks.FindAsync( + [compensateContext.Log.TrackId], + cancellationToken: compensateContext.CancellationToken); + + if (track is null) + return compensateContext.Compensated(); + + track.OriginalPictureName = compensateContext.Log.PreviousOriginalPictureKey; + track.SmallPictureName = compensateContext.Log.PreviousSmallPictureKey; + track.MediumPictureName = compensateContext.Log.PreviousMediumPictureKey; + track.LargePictureName = compensateContext.Log.PreviousLargePictureKey; + track.PicturesProcessingStatus = ProcessingStatus.Failed; + + database.Tracks.Update(track); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate track {TrackId} pictures", compensateContext.Log.TrackId); + return compensateContext.Failed(exception); + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs new file mode 100644 index 0000000..ec77a49 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal class DeletePlayListFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "delete-playList-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); + + if (playList is null) + { + logger.LogInformation( + "PlayList {PlayListId} not found while deleting from DB, skipping", + executeContext.Arguments.PlayListId); + return executeContext.Completed(); + } + + database.PlayLists.Remove(playList); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id); + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs new file mode 100644 index 0000000..e9a8c36 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs @@ -0,0 +1,37 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal class MarkPlayListAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-playList-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); + + if (playList is null) + { + logger.LogInformation( + "PlayList {PlayListId} not found while marking as removing, skipping", + executeContext.Arguments.PlayListId); + return executeContext.Completed(); + } + + playList.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id); + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs new file mode 100644 index 0000000..7754510 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +{ + internal class PublishPlayListPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "publish-playList-picture-processing-event"; + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + + await publishEndpoint.Publish( + new UpdatePlayListPictureEvent( + arguments.PlayListId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); + + logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); + + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs new file mode 100644 index 0000000..28e4c15 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs @@ -0,0 +1,36 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class DeleteTrackFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "delete-track-from-db"; + + public async Task Execute(ExecuteContext executeContext) + { + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + + if (track is null) + { + logger.LogInformation( + "Track {TrackId} not found while deleting from DB, skipping", + executeContext.Arguments.TrackId); + return executeContext.Completed(); + } + + database.Tracks.Remove(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted track {TrackId} from DB", track.Id); + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs new file mode 100644 index 0000000..f54b42e --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs @@ -0,0 +1,37 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Domain.ValueObjects; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class MarkTrackAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "mark-track-as-removing"; + + public async Task Execute(ExecuteContext executeContext) + { + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + + if (track is null) + { + logger.LogInformation( + "Track {TrackId} not found while marking as removing, skipping", + executeContext.Arguments.TrackId); + return executeContext.Completed(); + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked track {TrackId} as removing", track.Id); + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs new file mode 100644 index 0000000..a82865c --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -0,0 +1,43 @@ +using MassTransit; +using Microsoft.Extensions.Logging; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Arguments; + +namespace Musify.Infrastructure.MassTransit.Activities.Tracks +{ + internal class PublishTrackProcessingEventsActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity + { + public const string ExecuteEndpointName = "publish-track-processing-events"; + + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; + + await publishEndpoint.Publish( + new UpdateTrackPictureEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); + + await publishEndpoint.Publish( + new UpdateTrackAudioEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalAudioKey, + arguments.Bucket, + arguments.AudioDestinationFolderKey), + executeContext.CancellationToken); + + logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); + + return executeContext.Completed(); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs b/MusifyBackend/Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs new file mode 100644 index 0000000..1582d32 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs @@ -0,0 +1,62 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Logs; + +namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents +{ + internal class ConsumeUploadIntentsActivity( + IDatabase database, + ILogger logger) + : IActivity + { + public const string ExecuteEndpointName = "consume-upload-intents"; + + public async Task Execute(ExecuteContext executeContext) + { + var intentIds = executeContext.Arguments.IntentIds; + + var intents = await database.UploadIntents + .Where(i => intentIds.Contains(i.Id)) + .ToListAsync(executeContext.CancellationToken); + + foreach (var intent in intents) + intent.Status = UploadIntentStatus.Consumed; + + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); + + return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var intentIds = compensateContext.Log.IntentIds; + + var intents = await database.UploadIntents + .Where(i => intentIds.Contains(i.Id)) + .ToListAsync(compensateContext.CancellationToken); + + foreach (var intent in intents) + intent.Status = UploadIntentStatus.Issued; + + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate upload intent consumption"); + return compensateContext.Failed(exception); + } + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs new file mode 100644 index 0000000..70cd9c2 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ConsumeUploadIntentsArguments( + Guid[] IntentIds); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs new file mode 100644 index 0000000..ade186a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs @@ -0,0 +1,8 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record CopyFileInBucketArguments( + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationKey); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs new file mode 100644 index 0000000..b455ed9 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record DeletePlayListFromDbArguments( + Guid PlayListId); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs new file mode 100644 index 0000000..982e1ae --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record DeleteTrackFromDbArguments( + Guid TrackId); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs new file mode 100644 index 0000000..1579a91 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record DownloadFileFromBucketArguments( + string Bucket, + string Key, + string DestinationFilePathVariable); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs new file mode 100644 index 0000000..0786562 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GenerateAudioWorkflowPathsArguments( + Guid TrackId, + string TemporaryRootDirectory, + string SourceKey); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs new file mode 100644 index 0000000..330a3b8 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GeneratePictureWorkflowPathsArguments( + string TemporaryRootDirectory, + string SourceKey); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs new file mode 100644 index 0000000..8cd63f7 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record MarkPlayListAsRemovingArguments( + Guid PlayListId); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs new file mode 100644 index 0000000..5731258 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record MarkTrackAsRemovingArguments( + Guid TrackId); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs new file mode 100644 index 0000000..5721ea8 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -0,0 +1,12 @@ +using Musify.Application.Shared; + +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishPlayListPictureProcessingEventArguments( + Guid PlayListId, + string Bucket, + string FinalPictureKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs new file mode 100644 index 0000000..a13a5e0 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -0,0 +1,14 @@ +using Musify.Application.Shared; + +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishTrackProcessingEventsArguments( + Guid TrackId, + string Bucket, + string FinalPictureKey, + string FinalAudioKey, + string AudioDestinationFolderKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs new file mode 100644 index 0000000..5f3e499 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record RemoveFileFromBucketArguments( + string Bucket, + string Key); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs new file mode 100644 index 0000000..022fc7c --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs @@ -0,0 +1,8 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ResizePictureLocalArguments( + string SourceFilePathVariable, + string DestinationFilePathVariable, + int Width, + int Height); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs new file mode 100644 index 0000000..1533480 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TranscodeAudioArguments( + Guid TrackId, + string SourceFilePathVariable, + string WorkingDirectoryVariable); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs new file mode 100644 index 0000000..48f804d --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TransferFilesToBucketArguments( + string DestinationBucket, + string SourceDirectoryVariable, + string DestinationKey); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs new file mode 100644 index 0000000..bb05ee6 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdatePlayListPictureArguments( + Guid PlayListId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs new file mode 100644 index 0000000..10edb2b --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateTrackAudioArguments( + Guid TrackId, + string AudioFolderKey); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs new file mode 100644 index 0000000..0c48ac3 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateTrackPictureArguments( + Guid TrackId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/MusifyBackend/Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs new file mode 100644 index 0000000..8fd35f8 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UploadFileToBucketArguments( + string FilePathVariable, + string DestinationBucket, + string DestinationRoute); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs new file mode 100644 index 0000000..218e9bf --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class CreatePlayListConsumer( + IBus bus, + PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "create-playList"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs new file mode 100644 index 0000000..06d4441 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class CreateTrackConsumer( + IBus bus, + CreateTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "create-track"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs new file mode 100644 index 0000000..8c19ed5 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -0,0 +1,27 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class DeletePlayListConsumer( + IBus bus, + DeletePlayListRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "delete-playList"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = (await routingSlipBuilder + .BuildAsync( + consumeContext.Message.PlayListId, + consumeContext.Message.UserId, + consumeContext.CorrelationId, + consumeContext.CancellationToken)) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs new file mode 100644 index 0000000..fbdc9a8 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class DeleteTrackConsumer( + IBus bus, + DeleteTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "delete-track"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = (await routingSlipBuilder + .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/EndpointHelper.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/EndpointHelper.cs new file mode 100644 index 0000000..47c8efd --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/EndpointHelper.cs @@ -0,0 +1,11 @@ +namespace Musify.Infrastructure.MassTransit.Consumers +{ + internal static class EndpointHelper + { + internal static Uri BuildExecuteActivityUri(string endpointName) => new($"queue:{endpointName}_execute"); + + internal static Uri BuildCompensateActivityUri(string endpointName) => new($"queue:{endpointName}_compensate"); + + internal static Uri BuildConsumerUri(string endpointName) => new($"queue:{endpointName}"); + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs new file mode 100644 index 0000000..ece1cda --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -0,0 +1,48 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Microsoft.Extensions.Logging; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class RoutingSlipCleanUpConsumer( + ILogger logger) : + IConsumer, + IConsumer + { + public const string QueueName = "routing-slip-events"; + + public Task Consume(ConsumeContext consumeContext) + => DeleteFolder(consumeContext.Message.Variables); + + public Task Consume(ConsumeContext consumeContext) + => DeleteFolder(consumeContext.Message.Variables); + + Task DeleteFolder(IDictionary variables) + { + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) + return Task.CompletedTask; + + var pathString = pathObject.ToString(); + + if (string.IsNullOrEmpty(pathString)) + return Task.CompletedTask; + + try + { + Directory.Delete(pathString, recursive: true); + logger.LogInformation("Deleted temporal directory at path {Path}", pathString); + } + catch (DirectoryNotFoundException directoryNotFoundException) + { + logger.LogWarning(directoryNotFoundException, "Temporal directory at path {Path} was not found for deletion", pathString); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete temporal directory at path {Path}", pathString); + throw; + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs new file mode 100644 index 0000000..fb93f7b --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdatePlayListPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "update-playList-picture"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs new file mode 100644 index 0000000..f24e9dd --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdatePlayListPictureSourceConsumer( + IBus bus, + PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "update-playList-picture-source"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs new file mode 100644 index 0000000..8cb1b5a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateTrackAudioConsumer( + IBus bus, + AudioWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "update-track-audio"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs new file mode 100644 index 0000000..396c6e0 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -0,0 +1,23 @@ +using MassTransit; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit.Consumers +{ + public class UpdateTrackPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public const string QueueName = "update-track-picture"; + + public async Task Consume(ConsumeContext consumeContext) + { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); + + await bus.Execute(routingSlip); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs new file mode 100644 index 0000000..82e8d35 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Endpoints.cs @@ -0,0 +1,91 @@ +using MassTransit; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.Persistence; + +namespace Musify.Infrastructure.MassTransit +{ + public static partial class MassTransitDependencyInjection + { + static void ConfigureConsumerEndpoint( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + IBusRegistrationContext busRegistrationContext, + string queueName) + where TConsumer : class, IConsumer + { + busFactoryConfigurator.ReceiveEndpoint(queueName, endpointConfigurator => + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.ConfigureConsumer(busRegistrationContext); + }); + } + + static void ConfigureExecuteActivityEndpoint( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + IBusRegistrationContext busRegistrationContext, + string endpointName) + where TActivity : class, IExecuteActivity + where TArguments : class + { + busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator => + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.ExecuteActivityHost(busRegistrationContext); + }); + } + + static void ConfigureActivityEndpoint( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + IBusRegistrationContext busRegistrationContext, + string endpointName) + where TActivity : class, IActivity + where TArguments : class + where TLog : class + { + busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.ExecuteQueue(endpointName), endpointConfigurator => + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.ExecuteActivityHost( + EndpointHelper.BuildCompensateActivityUri(endpointName), + busRegistrationContext); + }); + + busFactoryConfigurator.ReceiveEndpoint(MassTransitEndpointNames.CompensateQueue(endpointName), endpointConfigurator => + { + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); + + endpointConfigurator.UseEntityFrameworkOutbox(busRegistrationContext); + + endpointConfigurator.CompensateActivityHost(busRegistrationContext); + }); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs new file mode 100644 index 0000000..3255d0a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Musify.Infrastructure.MassTransit +{ + public static partial class MassTransitDependencyInjection + { + static void RegisterValidatedOptions( + IServiceCollection services, + IConfiguration configuration, + string sectionName) + where TOptions : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs new file mode 100644 index 0000000..457f956 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -0,0 +1,212 @@ +using MassTransit; +using Microsoft.Extensions.DependencyInjection; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Activities; +using Musify.Infrastructure.MassTransit.Activities.Audio; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Activities.Tracks; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Infrastructure.MassTransit.Logs; +using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +namespace Musify.Infrastructure.MassTransit +{ + public static partial class MassTransitDependencyInjection + { + static void RegisterRoutingSlipBuilders(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + + static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) + { + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + } + + static void ConfigureRabbitMqWorkerEndpoints( + IBusRegistrationContext busRegistrationContext, + IRabbitMqBusFactoryConfigurator busFactoryConfigurator) + { + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService()); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdatePlayListPictureConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdateTrackAudioConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdateTrackPictureConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeleteTrackConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeletePlayListConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + CreateTrackConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + CreatePlayListConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdatePlayListPictureSourceConsumer.QueueName); + + ConfigureConsumerEndpoint( + busFactoryConfigurator, + busRegistrationContext, + RoutingSlipCleanUpConsumer.QueueName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + RemoveFileFromBucketActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + GenerateAudioWorkflowPathsActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + GeneratePictureWorkflowPathsActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + MarkTrackAsRemovingActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeleteTrackFromDbActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + MarkPlayListAsRemovingActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DeletePlayListFromDbActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + PublishTrackProcessingEventsActivity.ExecuteEndpointName); + + ConfigureExecuteActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + ResizePictureActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdatePlayListPictureActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdateTrackPictureActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + DownloadFileFromBucketActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + TranscodeAudioActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + TransferFilesToBucketActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UpdateTrackAudioActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + UploadFileToBucketActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + CopyFileInBucketActivity.ExecuteEndpointName); + + ConfigureActivityEndpoint( + busFactoryConfigurator, + busRegistrationContext, + ConsumeUploadIntentsActivity.ExecuteEndpointName); + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs new file mode 100644 index 0000000..684a9f6 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -0,0 +1,85 @@ +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Persistence; + +namespace Musify.Infrastructure.MassTransit +{ + public static partial class MassTransitDependencyInjection + { + public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + RegisterValidatedOptions( + serviceDescriptors, + configuration, + MassTransitConfiguration.SectionName); + + serviceDescriptors.AddScoped(); + serviceDescriptors.AddMassTransit(options => + { + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService())); + } ); + + return serviceDescriptors; + } + + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + RegisterValidatedOptions( + serviceDescriptors, + configuration, + MassTransitConfiguration.SectionName); + + RegisterValidatedOptions( + serviceDescriptors, + configuration, + WorkerConfiguration.SectionName); + + RegisterRoutingSlipBuilders(serviceDescriptors); + + serviceDescriptors.AddMassTransit(options => + { + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + RegisterConsumersAndActivities(options); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + ConfigureRabbitMqWorkerEndpoints(busRegistrationContext, busFactoryConfigurator)); + } ); + + return serviceDescriptors; + } + + static void ConfigureRabbitMqHost( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + MassTransitConfiguration massTransitConfiguration) + { + busFactoryConfigurator.Host(massTransitConfiguration.Address, options => + { + options.Username(massTransitConfiguration.Username); + options.Password(massTransitConfiguration.Password); + } ); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitEndpointNames.cs b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitEndpointNames.cs new file mode 100644 index 0000000..cdcdffc --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/DependencyInjection/MassTransitEndpointNames.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit +{ + internal static class MassTransitEndpointNames + { + internal static string ExecuteQueue(string endpointName) => $"{endpointName}_execute"; + + internal static string CompensateQueue(string endpointName) => $"{endpointName}_compensate"; + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs new file mode 100644 index 0000000..275ddd5 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs @@ -0,0 +1,5 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ConsumeUploadIntentsLog( + Guid[] IntentIds); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs new file mode 100644 index 0000000..609258a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record CopyFileInBucketLog( + string DestinationBucket, + string DestinationKey); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs new file mode 100644 index 0000000..bb00b6e --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs @@ -0,0 +1,7 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record DownloadFileFromBucketLog( + string Bucket, + string Key, + string DestinationFilePath); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs new file mode 100644 index 0000000..9a9c223 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record GeneratePictureWorkflowPathsLog(string WorkingDirectory); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/ResizePictureLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/ResizePictureLog.cs new file mode 100644 index 0000000..f255d51 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/ResizePictureLog.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ResizePictureLog(string DestinationFilePath); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs new file mode 100644 index 0000000..c85933a --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TranscodeAudioLog(string WorkingDirectory); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs new file mode 100644 index 0000000..5db2002 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs new file mode 100644 index 0000000..8135ec8 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdatePlayListPictureLog( + Guid PlayListId, + string PreviousOriginalPictureKey, + string PreviousSmallPictureKey, + string PreviousMediumPictureKey, + string PreviousLargePictureKey); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs new file mode 100644 index 0000000..355a755 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -0,0 +1,6 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateTrackAudioLog( + Guid TrackId, + string? PreviousAudioFolderName); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs new file mode 100644 index 0000000..a634c2f --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs @@ -0,0 +1,9 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateTrackPictureLog( + Guid TrackId, + string PreviousOriginalPictureKey, + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); +} diff --git a/MusifyBackend/Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/MusifyBackend/Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs new file mode 100644 index 0000000..eac29f1 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs @@ -0,0 +1,4 @@ +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/MassTransitEventBus.cs b/MusifyBackend/Infrastructure/MassTransit/MassTransitEventBus.cs new file mode 100644 index 0000000..beaea4f --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/MassTransitEventBus.cs @@ -0,0 +1,16 @@ +using MassTransit; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit +{ + public class MassTransitEventBus(IPublishEndpoint publishEndpoint) + : IEventBus + { + public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class + { + ArgumentNullException.ThrowIfNull(message, nameof(message)); + + await publishEndpoint.Publish(message, cancellationToken); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs new file mode 100644 index 0000000..3767c6c --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -0,0 +1,51 @@ +namespace Musify.Infrastructure.MassTransit.RoutingSlip +{ + internal static class ActivityNames + { + internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; + internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; + internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; + internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; + + internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; + internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; + + internal const string DownloadFile = "DownloadFile"; + + internal const string TranscodeAudio = "TranscodeAudio"; + + internal const string ResizeSmall = "ResizeSmall"; + internal const string ResizeMedium = "ResizeMedium"; + internal const string ResizeLarge = "ResizeLarge"; + + internal const string UploadSmall = "UploadSmall"; + internal const string UploadMedium = "UploadMedium"; + internal const string UploadLarge = "UploadLarge"; + + internal const string TransferFiles = "TransferFiles"; + + internal const string UpdateTrackPicture = "UpdateTrackPicture"; + internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; + internal const string UpdateTrackAudio = "UpdateTrackAudio"; + + internal const string CopyPictureToFinal = "CopyPictureToFinal"; + internal const string CopyAudioToFinal = "CopyAudioToFinal"; + + internal const string ConsumeUploadIntents = "ConsumeUploadIntents"; + + internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; + internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; + + internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; + internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; + internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture"; + internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture"; + internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio"; + internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio"; + + internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture"; + internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; + internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; + internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs new file mode 100644 index 0000000..cbf30fe --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -0,0 +1,66 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Activities.Audio; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + { + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.GenerateAudioWorkflowPaths, + EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), + new GenerateAudioWorkflowPathsArguments( + message.TrackId, + workerConfiguration.Routes.TemporaryFilesDirectory, + message.SourceKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.DownloadFile, + EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), + new DownloadFileFromBucketArguments( + message.SourceBucket, + message.SourceKey, + RoutingSlipVariableNames.Audio.SourceFilePath)); + + routingSlipBuilder.AddActivity( + ActivityNames.TranscodeAudio, + EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)); + + routingSlipBuilder.AddActivity( + ActivityNames.TransferFiles, + EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateTrackAudio, + EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), + new UpdateTrackAudioArguments( + message.TrackId, + message.DestinationFolderKey)); + + return routingSlipBuilder; + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs new file mode 100644 index 0000000..fd9612f --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -0,0 +1,63 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Tracks; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class CreateTrackRoutingSlipBuilder + { + public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + message.Bucket, + message.PictureSourceKey, + message.Bucket, + message.PictureDestinationKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.CopyAudioToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + message.Bucket, + message.AudioSourceKey, + message.Bucket, + message.AudioDestinationKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments( + [message.PictureIntentId, message.AudioIntentId])); + + routingSlipBuilder.AddActivity( + ActivityNames.PublishTrackProcessingEvents, + EndpointHelper.BuildExecuteActivityUri(PublishTrackProcessingEventsActivity.ExecuteEndpointName), + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Small, + message.Medium, + message.Large)); + + return routingSlipBuilder; + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs new file mode 100644 index 0000000..4390061 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -0,0 +1,94 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class DeletePlayListRoutingSlipBuilder( + IDatabase database, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) + { + public async Task BuildAsync( + Guid playListId, + long userId, + Guid? correlationId, + CancellationToken cancellationToken) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var playList = await database.PlayLists + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkPlayListAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), + new MarkPlayListAsRemovingArguments(playListId)); + + if (playList is not null) + { + var bucket = storageConfiguration.Bucket; + var routes = playListConfiguration.Routes; + + if (playList.OriginalPictureName != routes.PresetOriginalPicture) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildOriginalPicturePath(userId, playList.OriginalPictureName))); + } + + if (playList.SmallPictureName != routes.PresetSmallPicture) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildSmallPicturePath(playList.SmallPictureName))); + } + + if (playList.MediumPictureName != routes.PresetMediumPicture) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildMediumPicturePath(playList.MediumPictureName))); + } + + if (playList.LargePictureName != routes.PresetLargePicture) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildLargePicturePath(playList.LargePictureName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeletePlayListFromDb, + EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), + new DeletePlayListFromDbArguments(playListId)); + + return routingSlipBuilder; + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs new file mode 100644 index 0000000..2c6a0f2 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -0,0 +1,101 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Tracks; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class DeleteTrackRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + ApplicationStorageConfiguration storageConfiguration) + { + public async Task BuildAsync( + Guid trackId, + long userId, + Guid? correlationId, + CancellationToken cancellationToken) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var track = await database.Tracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkTrackAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), + new MarkTrackAsRemovingArguments(trackId)); + + if (track is not null) + { + var bucket = storageConfiguration.Bucket; + + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.OriginalPictureName))); + + if (track.IsPicturesProcessed) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildSmallPicturePath(track.SmallPictureName))); + + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildMediumPicturePath(track.MediumPictureName))); + + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildLargePicturePath(track.LargePictureName))); + } + + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.OriginalAudioName))); + + if (track.IsAudioProcessed) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackProcessedAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName))); + } + } + + routingSlipBuilder.AddActivity( + ActivityNames.DeleteTrackFromDb, + EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName), + new DeleteTrackFromDbArguments(trackId)); + + return routingSlipBuilder; + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs new file mode 100644 index 0000000..0fcbf34 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -0,0 +1,144 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.MassTransit.Activities; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + { + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateTrackPicture, + EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), + new UpdateTrackPictureArguments( + message.TrackId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + return routingSlipBuilder; + } + + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdatePlayListPicture, + EndpointHelper.BuildExecuteActivityUri(UpdatePlayListPictureActivity.ExecuteEndpointName), + new UpdatePlayListPictureArguments( + message.PlayListId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + return routingSlipBuilder; + } + + RoutingSlipBuilder BuildPictureWorkflow( + string bucket, + string sourceKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.GeneratePictureWorkflowPaths, + EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), + new GeneratePictureWorkflowPathsArguments( + workerConfiguration.Routes.TemporaryFilesDirectory, + sourceKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.DownloadFile, + EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), + new DownloadFileFromBucketArguments( + bucket, + sourceKey, + RoutingSlipVariableNames.Picture.OriginalFilePath)); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeSmall, + ActivityNames.UploadSmall, + bucket, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + small); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeMedium, + ActivityNames.UploadMedium, + bucket, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + medium); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeLarge, + ActivityNames.UploadLarge, + bucket, + RoutingSlipVariableNames.Picture.LargeResizedFilePath, + large); + + return routingSlipBuilder; + } + + static void AddResizeActivity( + RoutingSlipBuilder routingSlipBuilder, + string resizeActivityName, + string uploadActivityName, + string bucketName, + string destinationFilePathVariableName, + ImageSize imageSize) + { + routingSlipBuilder.AddActivity( + resizeActivityName, + EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, + destinationFilePathVariableName, + imageSize.Width, + imageSize.Height)); + + routingSlipBuilder.AddActivity( + uploadActivityName, + EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), + new UploadFileToBucketArguments( + destinationFilePathVariableName, + bucketName, + imageSize.SavePath)); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs new file mode 100644 index 0000000..f863910 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs @@ -0,0 +1,85 @@ +using MassTransit; +using MassTransit.Courier.Contracts; +using Musify.Application.Shared; +using Musify.Application.Events; +using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.PlayLists; +using Musify.Infrastructure.MassTransit.Activities.UploadIntents; +using Musify.Infrastructure.MassTransit.Arguments; +using Musify.Infrastructure.MassTransit.Consumers; + +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +{ + public class PlayListPictureSourceRoutingSlipBuilder + { + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) + => BuildSlip( + message.PlayListId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) + => BuildSlip( + message.PlayListId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + static RoutingSlipBuilder BuildSlip( + Guid playListId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + bucket, + pictureSourceKey, + bucket, + pictureDestinationKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments([pictureIntentId])); + + routingSlipBuilder.AddActivity( + ActivityNames.PublishPlayListPictureProcessingEvent, + EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName), + new PublishPlayListPictureProcessingEventArguments( + playListId, + bucket, + pictureDestinationKey, + small, + medium, + large)); + + return routingSlipBuilder; + } + } +} diff --git a/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs new file mode 100644 index 0000000..2667a18 --- /dev/null +++ b/MusifyBackend/Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -0,0 +1,25 @@ +namespace Musify.Infrastructure.MassTransit +{ + internal static class RoutingSlipVariableNames + { + internal static class Workflow + { + internal const string TemporalDirectory = "Workflow.TemporalDirectory"; + internal const string CorrelationId = "Workflow.CorrelationId"; + } + + internal static class Audio + { + internal const string SourceFilePath = "Audio.SourceFilePath"; + internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; + } + + internal static class Picture + { + internal const string OriginalFilePath = "Picture.OriginalFilePath"; + internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath"; + internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath"; + internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath"; + } + } +} diff --git a/MusifyBackend/Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/MusifyBackend/Infrastructure/Observability/OpenTelemetryDependencyInjection.cs new file mode 100644 index 0000000..75c4903 --- /dev/null +++ b/MusifyBackend/Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Musify.Infrastructure.Configuration; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Musify.Infrastructure.Observability +{ + public static class OpenTelemetryDependencyInjection + { + public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors + .AddOptions() + .Configure((exporterOptions, openTelemetryConfiguration) => + { + exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); + } ); + + serviceDescriptors + .AddOpenTelemetry() + .ConfigureResource(resource => + { + resource.AddService("Musify"); + } ) + .WithTracing(tracing => + tracing.AddSource("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter() + ) + .WithMetrics(metrics => + metrics.AddMeter("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddOtlpExporter() + ) + .WithLogging(logging => + logging.AddOtlpExporter() + ); + + return serviceDescriptors; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Persistence/AuditableEntityInterceptor.cs b/MusifyBackend/Infrastructure/Persistence/AuditableEntityInterceptor.cs new file mode 100644 index 0000000..40c7e69 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Musify.Domain.Abstractions; + +namespace Musify.Infrastructure.Persistence +{ + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + ApplyTimestamps(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + ApplyTimestamps(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void ApplyTimestamps(DbContext? context) + { + if (context is null) + return; + + var now = DateTime.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = now; + } + } + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Database.cs b/MusifyBackend/Infrastructure/Persistence/Database.cs new file mode 100644 index 0000000..3723077 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Database.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using MassTransit; +using Musify.Domain.Entities; +using Musify.Infrastructure.Configuration; +using AppIDatabase = Musify.Application.Contracts.IDatabase; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Persistence +{ + public class Database(DatabaseConfiguration configuration) + : DbContext, AppIDatabase + { + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(configuration.ConnectionString); + optionsBuilder.AddInterceptors(new AuditableEntityInterceptor()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + } + + public DbSet Users => Set(); + + public DbSet Tracks => Set(); + + public DbSet PlayLists => Set(); + + public DbSet UserHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Uploads => Set(); + + public DbSet UploadIntents => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/DatabaseDependencyInjection.cs b/MusifyBackend/Infrastructure/Persistence/DatabaseDependencyInjection.cs new file mode 100644 index 0000000..6d3a9d9 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; + +namespace Musify.Infrastructure.Persistence +{ + public static class DatabaseDependencyInjection + { + public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddDbContext(); + serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + + return serviceDescriptors; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/MusifyBackend/Infrastructure/Persistence/DatabaseDesignTimeFactory.cs new file mode 100644 index 0000000..18f38a9 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using Musify.Infrastructure.Configuration; + +namespace Musify.Infrastructure.Persistence +{ + public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory + { + public Database CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("DesignSettings.json") + .AddUserSecrets() + .Build(); + + var databaseConfiguration = configuration + .GetRequiredSection(DatabaseConfiguration.SectionName) + .Get() + ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString); + + return new Database(databaseConfiguration); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs new file mode 100644 index 0000000..f8d67e5 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.Designer.cs @@ -0,0 +1,578 @@ + +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260530020027_InitialCreate")] + partial class InitialCreate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs new file mode 100644 index 0000000..eb0d10c --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260530020027_InitialCreate.cs @@ -0,0 +1,355 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + public partial class InitialCreate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxState", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumerId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Received = table.Column(type: "timestamp with time zone", nullable: false), + ReceiveCount = table.Column(type: "integer", nullable: false), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true), + Consumed = table.Column(type: "timestamp with time zone", nullable: true), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateTable( + name: "Tracks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + NormalizedTitle = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + OriginalPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SmallPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + MediumPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + LargePictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + OriginalAudioName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AudioFolderName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + PicturesProcessingStatus = table.Column(type: "integer", nullable: false), + AudioTranscodeProcessingStatus = table.Column(type: "integer", nullable: false), + LifeCycleStatus = table.Column(type: "integer", nullable: false), + RetryCount = table.Column(type: "integer", nullable: false), + LastRetryAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tracks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Upload", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Bucket = table.Column(type: "text", nullable: false), + Key = table.Column(type: "text", nullable: false), + ContentType = table.Column(type: "text", nullable: false), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + State = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Upload", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "User", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false), + Name = table.Column(type: "character varying(48)", maxLength: 48, nullable: false), + NormalizedName = table.Column(type: "character varying(48)", maxLength: 48, nullable: false), + FirstName = table.Column(type: "character varying(48)", maxLength: 48, nullable: true), + SecondName = table.Column(type: "character varying(48)", maxLength: 48, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_User", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + table.ForeignKey( + name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId", + columns: x => new { x.InboxMessageId, x.InboxConsumerId }, + principalTable: "InboxState", + principalColumns: new[] { "MessageId", "ConsumerId" }); + table.ForeignKey( + name: "FK_OutboxMessage_OutboxState_OutboxId", + column: x => x.OutboxId, + principalTable: "OutboxState", + principalColumn: "OutboxId"); + }); + + migrationBuilder.CreateTable( + name: "PlayLists", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + NormalizedName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + OriginalPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + SmallPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + MediumPictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + LargePictureName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + LifeCycleStatus = table.Column(type: "integer", nullable: false), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedDate = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayLists", x => x.Id); + table.ForeignKey( + name: "FK_PlayLists_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UploadIntents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + Bucket = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + ObjectName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ContentType = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpectedSizeBytes = table.Column(type: "bigint", nullable: true), + Purpose = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UploadIntents", x => x.Id); + table.ForeignKey( + name: "FK_UploadIntents_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_UserHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserHasTrack_User_UserId", + column: x => x.UserId, + principalTable: "User", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PlayListHasTrack", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PlayListId = table.Column(type: "uuid", nullable: false), + TrackId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + AddedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayListHasTrack", x => x.Id); + table.ForeignKey( + name: "FK_PlayListHasTrack_PlayLists_PlayListId", + column: x => x.PlayListId, + principalTable: "PlayLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PlayListHasTrack_Tracks_TrackId", + column: x => x.TrackId, + principalTable: "Tracks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + table: "OutboxState", + column: "Created"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_PlayListId", + table: "PlayListHasTrack", + column: "PlayListId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayListHasTrack_TrackId", + table: "PlayListHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayLists_UserId", + table: "PlayLists", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UploadIntents_UserId", + table: "UploadIntents", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_TrackId", + table: "UserHasTrack", + column: "TrackId"); + + migrationBuilder.CreateIndex( + name: "IX_UserHasTrack_UserId", + table: "UserHasTrack", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutboxMessage"); + + migrationBuilder.DropTable( + name: "PlayListHasTrack"); + + migrationBuilder.DropTable( + name: "Upload"); + + migrationBuilder.DropTable( + name: "UploadIntents"); + + migrationBuilder.DropTable( + name: "UserHasTrack"); + + migrationBuilder.DropTable( + name: "InboxState"); + + migrationBuilder.DropTable( + name: "OutboxState"); + + migrationBuilder.DropTable( + name: "PlayLists"); + + migrationBuilder.DropTable( + name: "Tracks"); + + migrationBuilder.DropTable( + name: "User"); + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs new file mode 100644 index 0000000..28bd9da --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.Designer.cs @@ -0,0 +1,583 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260531234306_AddUserProfilePictureUrl")] + partial class AddUserProfilePictureUrl + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs new file mode 100644 index 0000000..5342474 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260531234306_AddUserProfilePictureUrl.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserProfilePictureUrl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ProfilePictureUrl", + table: "User", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ProfilePictureUrl", + table: "User"); + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs new file mode 100644 index 0000000..279f318 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.Designer.cs @@ -0,0 +1,583 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260601195757_RenameTimestampsAndAudit")] + partial class RenameTimestampsAndAudit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs new file mode 100644 index 0000000..540250e --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/20260601195757_RenameTimestampsAndAudit.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class RenameTimestampsAndAudit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "UpdatedDate", + table: "Tracks", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedDate", + table: "Tracks", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "UpdatedDate", + table: "PlayLists", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "CreatedDate", + table: "PlayLists", + newName: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "UpdatedAt", + table: "Tracks", + newName: "UpdatedDate"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "Tracks", + newName: "CreatedDate"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + table: "PlayLists", + newName: "UpdatedDate"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + table: "PlayLists", + newName: "CreatedDate"); + } + } +} diff --git a/MusifyBackend/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/MusifyBackend/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs new file mode 100644 index 0000000..b6f5653 --- /dev/null +++ b/MusifyBackend/Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -0,0 +1,580 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + partial class DatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LargePictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SmallPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AudioFolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AudioTranscodeProcessingStatus") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LargePictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("MediumPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OriginalAudioName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalPictureName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PicturesProcessingStatus") + .HasColumnType("integer"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SmallPictureName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("PlayListTracks"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MusifyBackend/Infrastructure/Services/AudioTranscoderService.cs b/MusifyBackend/Infrastructure/Services/AudioTranscoderService.cs new file mode 100644 index 0000000..c3edd07 --- /dev/null +++ b/MusifyBackend/Infrastructure/Services/AudioTranscoderService.cs @@ -0,0 +1,215 @@ +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; +using System.Diagnostics; + +namespace Musify.Infrastructure.Services +{ + public class AudioTranscoderService(ILogger logger, + AudioTranscoderConfiguration audioTranscoderConfiguration) + : IAudioTranscoderService + { + record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); + + public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) + { + try + { + Directory.CreateDirectory(destinationPath); + + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + + var executionResult = await ExecuteFfmpegWithInputAsync( + BuildAudioFileArguments(destinationPath, audioTranscoderConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); + + if (executionResult.ExitCode != 0) + { + logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", + executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); + } + else + { + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + } + + return executionResult.ExitCode; + } + catch (TimeoutException timeoutException) + { + logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioTranscoderConfiguration.TranscodingTimeout, destinationPath); + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath); + throw; + } + } + + public async Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath); + return false; + } + + try + { + logger.LogDebug("Validating audio file {FilePath}", filePath); + + var executionResult = await ExecuteFfmpegAsync( + BuildValidateAudioArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + "ValidateAudioFile", + cancellationToken); + + var isValid = executionResult.ExitCode == 0; + if (!isValid) + { + logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError); + } + else + { + logger.LogDebug("Audio validation successful for {FilePath}", filePath); + } + + return isValid; + } + catch (TimeoutException timeoutException) + { + logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath); + } + return false; + } + + async Task ExecuteFfmpegWithInputAsync( + string arguments, + string workingDirectory, + Stream inputStream, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + + if (inputStream.CanSeek) + inputStream.Position = 0; + + await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + + return await WaitForProcessResultAsync(process, operationName, cancellationToken); + } + + async Task ExecuteFfmpegAsync( + string arguments, + string workingDirectory, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + + return await WaitForProcessResultAsync(process, operationName, cancellationToken); + } + + Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) + => new() + { + StartInfo = new ProcessStartInfo + { + FileName = audioTranscoderConfiguration.Ffmpeg.ExecutableName, + WorkingDirectory = workingDirectory, + Arguments = arguments, + RedirectStandardInput = redirectStandardInput, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + async Task WaitForProcessResultAsync( + Process process, + string operationName, + CancellationToken cancellationToken) + { + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + + using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCancellationTokens.CancelAfter(audioTranscoderConfiguration.TranscodingTimeout); + + try + { + await process.WaitForExitAsync(linkedCancellationTokens.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId})", + operationName, audioTranscoderConfiguration.TranscodingTimeout, process.Id); + + TryKillProcess(process); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioTranscoderConfiguration.TranscodingTimeout}."); + } + + var standardError = await errorTask; + var standardOutput = await outputTask; + + logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", + operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + + return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); + } + + void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", process.Id); + } + } + + static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + { + var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); + var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) + ? string.Empty + : $" {ffmpegConfiguration.AdditionalArguments.Trim()}"; + + return "-y -hide_banner -loglevel error " + + "-i pipe:0 " + + "-vn " + + "-map 0:a " + + $"-c:a {ffmpegConfiguration.AudioCodec} " + + $"-b:a {ffmpegConfiguration.AudioBitrate} " + + $"-ac {ffmpegConfiguration.AudioChannels} " + + $"-ar {ffmpegConfiguration.AudioSampleRate} " + + $"-profile:a {ffmpegConfiguration.AudioProfile} " + + "-movflags +faststart" + + additionalArguments + + $" \"{outputPath}\""; + } + + static string BuildValidateAudioArguments(string filePath) + => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Services/PictureService.cs b/MusifyBackend/Infrastructure/Services/PictureService.cs new file mode 100644 index 0000000..4237519 --- /dev/null +++ b/MusifyBackend/Infrastructure/Services/PictureService.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace Musify.Infrastructure.Services +{ + public class PictureService(ILogger logger) + : IPictureService + { + public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) + { + try + { + logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); + if (pictureStream.CanSeek && pictureStream.Position > 0) + pictureStream.Position = 0; + + using var picture = await Image.LoadAsync(pictureStream, cancellationToken); + picture.Mutate(options => options.Resize(new ResizeOptions + { + Size = new Size(width, height), + Mode = ResizeMode.Max + } )); + + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height); + throw; + } + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Services/ServicesDependencyInjection.cs b/MusifyBackend/Infrastructure/Services/ServicesDependencyInjection.cs new file mode 100644 index 0000000..45df273 --- /dev/null +++ b/MusifyBackend/Infrastructure/Services/ServicesDependencyInjection.cs @@ -0,0 +1,100 @@ +using Amazon.S3; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Musify.Application.Configuration; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Jobs; +using Musify.Application.Services; +using Musify.Application.Contracts; + +namespace Musify.Infrastructure.Services +{ + public static class ServicesDependencyInjection + { + public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddScoped(serviceProvider => + { + var configuration = serviceProvider.GetRequiredService(); + var s3Configuration = new AmazonS3Config + { + ServiceURL = configuration.Address, + ForcePathStyle = configuration.ForcePathStyle, + UseHttp = configuration.UseHttp, + }; + return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); + } ); + + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + + public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + + public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(UploadIntentConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddScoped(); + + return serviceDescriptors; + } + + public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddUploadIntentConfiguration(configuration); + + serviceDescriptors.AddHostedService(); + serviceDescriptors.AddHostedService(); + + return serviceDescriptors; + } + + public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(); + return serviceDescriptors; + } + + public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(AudioTranscoderConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Services/StorageService.cs b/MusifyBackend/Infrastructure/Services/StorageService.cs new file mode 100644 index 0000000..b07c765 --- /dev/null +++ b/MusifyBackend/Infrastructure/Services/StorageService.cs @@ -0,0 +1,299 @@ +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Transfer; +using Microsoft.Extensions.Logging; +using MimeMapping; +using Musify.Application.Contracts; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Configuration; +using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; + +namespace Musify.Infrastructure.Services +{ + public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger logger, + InfrastructureStorageConfiguration storageClientConfiguration) + : IStorageService + { + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + { + var request = new GetObjectRequest + { + BucketName = bucket, + Key = key, + }; + + try + { + var response = await amazonS3.GetObjectAsync(request, cancellationToken); + logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); + return response.ResponseStream; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); + throw; + } + } + + public async Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) + { + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS + }; + + logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } + + public async Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + CancellationToken cancellationToken, + bool preventOverwrite = true) + { + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + Verb = HttpVerb.PUT, + ContentType = contentType + }; + + if (preventOverwrite) + request.Headers["If-None-Match"] = "*"; + + logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } + + public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) + { + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + catch (Exception exception) + { + logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); + throw; + } + } + + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) + { + var request = new DeleteObjectRequest + { + BucketName = bucket, + Key = key, + }; + + logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); + await amazonS3.DeleteObjectAsync(request, cancellationToken); + } + + public async Task TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + { + var trasnsferUtility = new TransferUtility(amazonS3); + + var files = Directory.GetFiles(sourceDirectory, "*"); + + var successCount = 0; + var failedCount = 0; + + foreach (var file in files) + { + string fileName = Path.GetFileName(file); + string filePath = Path.Combine(sourceDirectory, file); + + var key = string.Join('/', new[] { route, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + var contentType = MimeUtility.GetMimeMapping(filePath); + var upload = new Upload + { + Id = Guid.NewGuid(), + Bucket = bucket, + Key = key, + ContentType = contentType + }; + await database.Uploads.AddAsync(upload, cancellationToken); + + try + { + await trasnsferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = contentType + }, + cancellationToken); + upload.State = UploadState.Successful; + successCount++; + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); + } + catch (Exception exception) + { + failedCount++; + upload.State = UploadState.Failed; + logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); + throw; + } + } + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); + } + + public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) + { + try + { + using var fileStream = File.OpenRead(filePath); + var contentType = MimeUtility.GetMimeMapping(filePath); + await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); + throw; + } + } + + public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) + { + var request = new PutObjectRequest + { + BucketName = bucket, + ContentType = contentType, + InputStream = sourceStream, + Key = key + }; + + var upload = new Upload + { + Id = Guid.NewGuid(), + Bucket = bucket, + Key = key, + ContentType = contentType + }; + await database.Uploads.AddAsync(upload, cancellationToken); + + try + { + var response = await amazonS3.PutObjectAsync(request, cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); + upload.State = UploadState.Failed; + await database.SaveChangesAsync(cancellationToken); + throw; + } + + upload.State = UploadState.Successful; + await database.SaveChangesAsync(cancellationToken); + logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key); + } + + public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + CancellationToken cancellationToken) + { + var request = new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }; + + try + { + var response = await amazonS3.CopyObjectAsync(request, cancellationToken); + logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); + throw; + } + } + + public async IAsyncEnumerable ListObjectsAsync( + string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var listRequest = new ListObjectsV2Request + { + BucketName = bucket, + Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" + }; + + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + foreach (var s3Object in listResponse.S3Objects ?? [] ) + yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); + + listRequest.ContinuationToken = listResponse.NextContinuationToken; + } + while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } + + public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) + { + var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; + var listRequest = new ListObjectsV2Request + { + BucketName = bucket, + Prefix = prefix + }; + + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + + if (listResponse.S3Objects is null or { Count: 0 }) + break; + + var deleteRequest = new DeleteObjectsRequest + { + BucketName = bucket, + Objects = listResponse.S3Objects + .Select(o => new KeyVersion { Key = o.Key } ) + .ToList() + }; + + var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); + + if (deleteResponse.DeleteErrors.Count > 0) + { + var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}")); + throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); + } + + listRequest.ContinuationToken = listResponse.NextContinuationToken; + + } while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } + } +} \ No newline at end of file diff --git a/MusifyBackend/Infrastructure/Services/StreamTicketService.cs b/MusifyBackend/Infrastructure/Services/StreamTicketService.cs new file mode 100644 index 0000000..10f26d4 --- /dev/null +++ b/MusifyBackend/Infrastructure/Services/StreamTicketService.cs @@ -0,0 +1,52 @@ +using System.Security.Cryptography; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; + +namespace Musify.Infrastructure.Services +{ + public sealed class StreamTicketService : IStreamTicketService, IDisposable + { + private readonly StreamTicketConfiguration configuration; + private readonly RSA rsa; + private readonly SigningCredentials signingCredentials; + private readonly JsonWebTokenHandler tokenHandler = new(); + + public StreamTicketService(StreamTicketConfiguration configuration) + { + this.configuration = configuration; + + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); + + var key = new RsaSecurityKey(rsa); + signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + } + + public StreamTicket IssueTicket(long userId, string keyPrefix) + { + var now = DateTimeOffset.UtcNow; + + var descriptor = new SecurityTokenDescriptor + { + Issuer = configuration.Issuer, + Audience = configuration.Audience, + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, + Claims = new Dictionary + { + ["sub"] = userId.ToString(), + ["prefix"] = keyPrefix, + }, + SigningCredentials = signingCredentials, + }; + + var token = tokenHandler.CreateToken(descriptor); + return new StreamTicket(token, configuration.TicketTtlSeconds); + } + + public void Dispose() => rsa.Dispose(); + } +} diff --git a/MusifyBackend/MusifyBackend.slnx b/MusifyBackend/MusifyBackend.slnx new file mode 100644 index 0000000..c92b66b --- /dev/null +++ b/MusifyBackend/MusifyBackend.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/MusifyBackend/skills-lock.json b/MusifyBackend/skills-lock.json new file mode 100644 index 0000000..1ab5b91 --- /dev/null +++ b/MusifyBackend/skills-lock.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "skills": { + "csharp-async": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "213d4b0921836fdf1ea3d218bc3625f48d149463f9bc765aac0eb343f31cfe37" + }, + "csharp-docs": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "03ba5f2f605625202803d1cc79353e10b4f3ff720b93992aeecc8d88a1e44720" + }, + "csharp-mstest": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "abb51cb248cb42f92716dc6568da8c33a9ea23af9a34155ef3ba156a61d9693d" + }, + "csharp-nunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "0493f7b95a519b13685335a32c9ff86e4eaca53e44a4f6795d11f72f899c683d" + }, + "csharp-tunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "5c6139f8cf75687df89a7c8c8fe8834676ffb496b632433973c84e128b52c2f6" + }, + "csharp-xunit": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "808c2d86afd16bccaba5f6d499361eb1da4d3e687a3305aa376ad4d40382e27d" + }, + "dotnet-best-practices": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "98bd60f0529ffbb6b6319e61e952ce11ae9deb99106212d1070007bc8ae6708f" + }, + "dotnet-design-pattern-review": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "60e201c6f3bd2832ed3834a8bb75143b2e6647e6026c5cbdad62f7706700c496" + }, + "dotnet-upgrade": { + "source": "github/awesome-copilot", + "sourceType": "autoskills-registry", + "computedHash": "77925fe0898ceeab04c574390815e7500a2426a26036a357325ce751f23cce71" + } + } +}