chore: import MusifyBackend into monorepo (preserving history)
git-subtree-dir: MusifyBackend git-subtree-mainline:6829e769edgit-subtree-split:ed7b00c2cc
This commit is contained in:
@@ -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<T>` when the method returns a value
|
||||||
|
- Return `Task` when the method doesn't return a value
|
||||||
|
- Consider `ValueTask<T>` 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<T>) 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.
|
||||||
@@ -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 `<summary>` 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 `<remarks>` for additional information, which can include implementation details, usage notes, or any other relevant context.
|
||||||
|
- Use `<see langword>` for language-specific keywords like `null`, `true`, `false`, `int`, `bool`, etc.
|
||||||
|
- Use `<c>` for inline code snippets.
|
||||||
|
- Use `<example>` for usage examples on how to use the member.
|
||||||
|
- Use `<code>` for code blocks. `<code>` tags should be placed within an `<example>` tag. Add the language of the code example using the `language` attribute, for example, `<code language="csharp">`.
|
||||||
|
- Use `<see cref>` to reference other types or members inline (in a sentence).
|
||||||
|
- Use `<seealso>` for standalone (not in a sentence) references to other types or members in the "See also" section of the online docs.
|
||||||
|
- Use `<inheritdoc/>` to inherit documentation from base classes or interfaces.
|
||||||
|
- Unless there is major behavior change, in which case you should document the differences.
|
||||||
|
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
- Use `<param>` 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 "`<see langword="true" />` to ...; otherwise, `<see langword="false" />`.".
|
||||||
|
- 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 `<paramref>` to reference parameter names in documentation.
|
||||||
|
- Use `<typeparam>` to describe type parameters in generic types or methods.
|
||||||
|
- Use `<typeparamref>` to reference type parameters in documentation.
|
||||||
|
- Use `<returns>` 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 "`<see langword="true" />` if ...; otherwise, `<see langword="false" />`.".
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
|
- The summary wording should be "Initializes a new instance of the <Class> class [or struct].".
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
|
||||||
|
- The `<summary>` 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 `<value>` 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 `<see langword="false" />`".
|
||||||
|
- If the value type is Boolean, the wording should be of the form "`<see langword="true" />` if ...; otherwise, `<see langword="false" />`. The default is ...".
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
|
||||||
|
- Use `<exception cref>` 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."
|
||||||
@@ -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<ArgumentException>(() => Method(null));
|
||||||
|
Assert.AreEqual("Value cannot be null.", ex.Message);
|
||||||
|
|
||||||
|
// Assert.ThrowsExactly - matches exact type only
|
||||||
|
var ex = Assert.ThrowsExactly<InvalidOperationException>(() => Method());
|
||||||
|
|
||||||
|
// Async versions
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(async () => await client.GetAsync(url));
|
||||||
|
var ex = await Assert.ThrowsExactlyAsync<InvalidOperationException>(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<MyClass>(obj, out var typed);
|
||||||
|
typed.DoSomething();
|
||||||
|
|
||||||
|
// MSTest 4.x - returns typed result directly
|
||||||
|
var typed = Assert.IsInstanceOfType<MyClass>(obj);
|
||||||
|
typed.DoSomething();
|
||||||
|
|
||||||
|
Assert.IsNotInstanceOfType<WrongType>(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<Tuple<T1, T2, ...>>` - provides type safety
|
||||||
|
- `IEnumerable<TestDataRow>` - provides type safety plus control over test metadata (display name, categories)
|
||||||
|
- `IEnumerable<object[]>` - **least preferred**, no type safety
|
||||||
|
|
||||||
|
> **Note:** When creating new test data methods, prefer `ValueTuple` or `TestDataRow` over `IEnumerable<object[]>`. 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<TestDataRow<(int a, int b, int expected)>> 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<object[]> - avoid for new code (no type safety)
|
||||||
|
public static IEnumerable<object[]> 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<ArgumentException>(() => 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<MyHandler>(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
|
||||||
@@ -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<T>` or `Assert.ThrowsAsync<T>` 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
|
||||||
@@ -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<TException>()` or `await Assert.That(asyncAction).ThrowsAsync<TException>()` 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<T>]` 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<T>]` with custom limit classes to control concurrency
|
||||||
|
- Tests within the same class run sequentially by default
|
||||||
|
- Use `[Repeat(n)]` with `[ParallelLimit<T>]` 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<T>` with `await Assert.That(action).Throws<T>()`
|
||||||
|
- Replace constructor/IDisposable with `[Before(Test)]`/`[After(Test)]`
|
||||||
|
- Replace `IClassFixture<T>` 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.
|
||||||
@@ -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<T>` for shared context between tests in a class
|
||||||
|
- Use `ICollectionFixture<T>` 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<T>` or `await Assert.ThrowsAsync<T>` 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
|
||||||
@@ -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<TOptions>`)
|
||||||
|
- 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<T> 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
|
||||||
@@ -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<TOptions>`), `ICommandHandler<TOptions>` 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.
|
||||||
@@ -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."
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Copilot Instructions
|
||||||
|
|
||||||
|
## Project Guidelines
|
||||||
|
- No crear pruebas/tests si el usuario no lo solicita explícitamente; evitar cambios no pedidos.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Musify.Application</RootNamespace>
|
||||||
|
<PackageId>Musify.Application</PackageId>
|
||||||
|
<Version>0.1.2</Version>
|
||||||
|
<Authors>NotS1mp</Authors>
|
||||||
|
<RepositoryUrl>https://github.com/XShine555/MusifyBackend</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Ardalis.Result" Version="10.1.0" />
|
||||||
|
<PackageReference Include="MassTransit" Version="8.5.9" />
|
||||||
|
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
|
||||||
|
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" />
|
||||||
|
<PackageReference Include="X.PagedList" Version="10.5.9" />
|
||||||
|
<PackageReference Include="X.PagedList.EF" Version="10.5.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Domain\Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApplicationStorageConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(ApplicationStorageConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<ApplicationStorageConfiguration>>().Value);
|
||||||
|
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPlayListConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
serviceDescriptors
|
||||||
|
.AddOptionsWithValidateOnStart<PlayListConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(PlayListConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<PlayListConfiguration>>().Value);
|
||||||
|
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddTrackConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
serviceDescriptors
|
||||||
|
.AddOptionsWithValidateOnStart<TrackConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(TrackConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<TrackConfiguration>>().Value);
|
||||||
|
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddStreamGatewayConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
serviceDescriptors
|
||||||
|
.AddOptionsWithValidateOnStart<StreamGatewayConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(StreamGatewayConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<StreamGatewayConfiguration>>().Value);
|
||||||
|
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IAudioTranscoderService
|
||||||
|
{
|
||||||
|
Task<int> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IDatabase
|
||||||
|
{
|
||||||
|
DbSet<User> Users { get; }
|
||||||
|
|
||||||
|
DbSet<PlayList> PlayLists { get; }
|
||||||
|
|
||||||
|
DbSet<Track> Tracks { get; }
|
||||||
|
|
||||||
|
DbSet<UserHasTrack> UserHasTracks { get; }
|
||||||
|
|
||||||
|
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||||
|
|
||||||
|
DbSet<Upload> Uploads { get; }
|
||||||
|
|
||||||
|
DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
|
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IDatabaseTransaction : IAsyncDisposable
|
||||||
|
{
|
||||||
|
Task CommitAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IEventBus
|
||||||
|
{
|
||||||
|
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IPictureService
|
||||||
|
{
|
||||||
|
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IStorageService
|
||||||
|
{
|
||||||
|
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string> 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<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public interface IStreamTicketService
|
||||||
|
{
|
||||||
|
StreamTicket IssueTicket(long userId, string keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public record ObjectMetaData(string ContentType, long ContentLength);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.Contracts
|
||||||
|
{
|
||||||
|
public record StorageObject(string Key, DateTime LastModifiedUtc);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Events
|
||||||
|
{
|
||||||
|
public record DeletePlayListEvent(
|
||||||
|
Guid PlayListId,
|
||||||
|
long UserId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Events
|
||||||
|
{
|
||||||
|
public record DeleteTrackEvent(
|
||||||
|
Guid TrackId,
|
||||||
|
long UserId);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Application.Events
|
||||||
|
{
|
||||||
|
public record UpdateTrackAudioEvent(
|
||||||
|
Guid TrackId,
|
||||||
|
string SourceBucket,
|
||||||
|
string SourceKey,
|
||||||
|
string DestinationBucket,
|
||||||
|
string DestinationFolderKey);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Ardalis.Result;
|
||||||
|
|
||||||
|
namespace Musify.Application.Extensions;
|
||||||
|
|
||||||
|
public static class ResultExtensions
|
||||||
|
{
|
||||||
|
public static Result<TOut> As<TIn, TOut>(this Result<TIn> 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<TOut>.NotFound(result.Errors.ToArray()),
|
||||||
|
ResultStatus.Forbidden => Result<TOut>.Forbidden(result.Errors.ToArray()),
|
||||||
|
ResultStatus.Unauthorized => Result<TOut>.Unauthorized(result.Errors.ToArray()),
|
||||||
|
ResultStatus.Invalid => Result<TOut>.Invalid(result.ValidationErrors.ToArray()),
|
||||||
|
ResultStatus.Conflict => Result<TOut>.Conflict(result.Errors.ToArray()),
|
||||||
|
ResultStatus.CriticalError => Result<TOut>.CriticalError(result.Errors.ToArray()),
|
||||||
|
ResultStatus.Unavailable => Result<TOut>.Unavailable(result.Errors.ToArray()),
|
||||||
|
ResultStatus.Error => Result<TOut>.Error(string.Join(";", result.Errors)),
|
||||||
|
_ => Result<TOut>.Error(string.Join(";", result.Errors))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using X.PagedList;
|
||||||
|
|
||||||
|
namespace Musify.Application.Pagination
|
||||||
|
{
|
||||||
|
public record PaginatedResponse<T>(
|
||||||
|
IReadOnlyCollection<T> Items,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize,
|
||||||
|
int PageCount,
|
||||||
|
int TotalItemCount,
|
||||||
|
bool HasNextPage,
|
||||||
|
bool HasPreviousPage)
|
||||||
|
{
|
||||||
|
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
|
||||||
|
{
|
||||||
|
return new PaginatedResponse<T>(
|
||||||
|
pagedList.ToArray(),
|
||||||
|
pagedList.PageNumber,
|
||||||
|
pagedList.PageSize,
|
||||||
|
pagedList.PageCount,
|
||||||
|
pagedList.TotalItemCount,
|
||||||
|
pagedList.HasNextPage,
|
||||||
|
pagedList.HasPreviousPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Ardalis.Result;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists.Commands
|
||||||
|
{
|
||||||
|
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
}
|
||||||
@@ -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<Result<PlayListApplicationResponse>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Ardalis.Result;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists.Commands
|
||||||
|
{
|
||||||
|
public record DeletePlayListCommand(long UserId, Guid PlayListId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Ardalis.Result;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists.Commands
|
||||||
|
{
|
||||||
|
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
}
|
||||||
@@ -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<Result<PlayListPictureUploadResponse>>;
|
||||||
|
}
|
||||||
@@ -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<Result<PlayListApplicationResponse>>;
|
||||||
|
}
|
||||||
@@ -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<AddTrackToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTrackToPlayListCommand, Result>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CreatePlayListCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
|
: ICommandHandler<CreatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PlayListApplicationResponse>> 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<UploadIntent, PlayListApplicationResponse>();
|
||||||
|
|
||||||
|
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<Result<PlayListApplicationResponse>> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeletePlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<DeletePlayListCommand, Result>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetPlayListByIdQuery, Result<PlayListApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PlayListApplicationResponse>> 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<PlayListApplicationResponse>.NotFound()
|
||||||
|
: Result.Success(playList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetPlayListTracksQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playListExists = await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (!playListExists)
|
||||||
|
return Result<PaginatedResponse<TrackApplicationResponse>>.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<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetPlayListsByUserIdQuery, Result<PaginatedResponse<PlayListApplicationResponse> > >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<PlayListApplicationResponse> >> 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<PlayListApplicationResponse>.FromPagedList(pagedPlayLists));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetPlayListsQuery, Result<PaginatedResponse<PlayListApplicationResponse> >>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<PlayListApplicationResponse> >> 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<PlayListApplicationResponse>.FromPagedList(pagedPlayLists));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RemoveTrackFromPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<RemoveTrackFromPlayListCommand, Result>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -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<RequestPlayListPictureUploadCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
|
: ICommandHandler<RequestPlayListPictureUploadCommand, Result<PlayListPictureUploadResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PlayListPictureUploadResponse>> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UpdatePlayListCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
|
: ICommandHandler<UpdatePlayListCommand, Result<PlayListApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PlayListApplicationResponse>> 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<UploadIntent, PlayListApplicationResponse>();
|
||||||
|
|
||||||
|
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<Result<PlayListApplicationResponse>> 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<PlayListApplicationResponse>>;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<PlayListApplicationResponse>> >;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Result> 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<Result<UploadIntent>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public record ImageSize(
|
||||||
|
string SavePath,
|
||||||
|
int Width,
|
||||||
|
int Height);
|
||||||
|
}
|
||||||
@@ -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('/', '\\')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<TrackApplicationResponse>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Ardalis.Result;
|
||||||
|
using Mediator;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks.Commands
|
||||||
|
{
|
||||||
|
public record DeleteTrackCommand(
|
||||||
|
long UserId,
|
||||||
|
Guid TrackId)
|
||||||
|
: ICommand<Result>;
|
||||||
|
}
|
||||||
@@ -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<Result<TrackUploadUrlsResponse>>;
|
||||||
|
}
|
||||||
@@ -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<CreateTrackCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
|
: ICommandHandler<CreateTrackCommand, Result<TrackApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<TrackApplicationResponse>> 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<UploadIntent, TrackApplicationResponse>();
|
||||||
|
|
||||||
|
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
|
uploadIntentConfiguration,
|
||||||
|
request.AudioIntentId, request.UserId, cancellationToken);
|
||||||
|
if (!audioValidation.IsSuccess)
|
||||||
|
return audioValidation.As<UploadIntent, TrackApplicationResponse>();
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeleteTrackCommandHandler> logger)
|
||||||
|
: ICommandHandler<DeleteTrackCommand, Result>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetTrackByIdQuery, Result<TrackApplicationResponse> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<TrackApplicationResponse>> 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<TrackApplicationResponse>.NotFound()
|
||||||
|
: Result.Success(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetTrackStreamQueryHandler> logger)
|
||||||
|
: IQueryHandler<GetTrackStreamQuery, Result<TrackStreamResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<TrackStreamResponse>> 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<TrackStreamResponse>.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<TrackStreamResponse>.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetTracksByUserIdQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> 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<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetTracksQuery, Result<PaginatedResponse<TrackApplicationResponse>> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<TrackApplicationResponse> >> 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<TrackApplicationResponse>.FromPagedList(pagedTracks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RequestTrackUploadUrlsCommandHandler> logger,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
|
: ICommandHandler<RequestTrackUploadUrlsCommand, Result<TrackUploadUrlsResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<TrackUploadUrlsResponse>> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<TrackApplicationResponse>>;
|
||||||
|
}
|
||||||
@@ -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<Result<TrackStreamResponse>>;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<TrackApplicationResponse> > >;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<TrackApplicationResponse> >>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record TrackStreamResponse(
|
||||||
|
string ManifestUrl,
|
||||||
|
string Ticket,
|
||||||
|
int ExpiresInSeconds);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Result<UserApplicationResponse>>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result>;
|
||||||
|
}
|
||||||
@@ -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<CreateUserCommandHandler> logger)
|
||||||
|
: ICommandHandler<CreateUserCommand, Result<UserApplicationResponse> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<UserApplicationResponse>> 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<UserApplicationResponse>.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetUserByIdQuery, Result<UserApplicationResponse> >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<UserApplicationResponse>> 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<UserApplicationResponse>.NotFound()
|
||||||
|
: Result.Success(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GetUsersQuery, Result<PaginatedResponse<UserApplicationResponse> > >
|
||||||
|
{
|
||||||
|
public async ValueTask<Result<PaginatedResponse<UserApplicationResponse> >> 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<UserApplicationResponse>.FromPagedList(pagedUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SyncUserCommandHandler> logger)
|
||||||
|
: ICommandHandler<SyncUserCommand, Result>
|
||||||
|
{
|
||||||
|
public async ValueTask<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<UserApplicationResponse> >;
|
||||||
|
}
|
||||||
@@ -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<Result<PaginatedResponse<UserApplicationResponse> >>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Domain.Abstractions
|
||||||
|
{
|
||||||
|
public interface IAuditable
|
||||||
|
{
|
||||||
|
DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Musify.Domain</RootNamespace>
|
||||||
|
<PackageId>Musify.Domain</PackageId>
|
||||||
|
<Version>0.1.2</Version>
|
||||||
|
<Authors>NotS1mp</Authors>
|
||||||
|
<RepositoryUrl>https://github.com/XShine555/MusifyBackend</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
|
||||||
|
|
||||||
|
public ICollection<PlayListHasTrack> PlayListTracks { get; set; } = new List<PlayListHasTrack>();
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserHasTrack> UserTracks { get; set; } = new List<UserHasTrack>();
|
||||||
|
|
||||||
|
public ICollection<PlayList> PlayLists { get; set; } = new List<PlayList>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum LifeCycleStatus
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Removing = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum ProcessingStatus
|
||||||
|
{
|
||||||
|
Pending = 0,
|
||||||
|
Processing = 1,
|
||||||
|
Completed = 2,
|
||||||
|
Failed = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user