diff --git a/src/MediaBrowser.Common/Media/MediaController.Tags.cs b/src/MediaBrowser.Common/Media/MediaController.Tags.cs index 225504e..662d255 100644 --- a/src/MediaBrowser.Common/Media/MediaController.Tags.cs +++ b/src/MediaBrowser.Common/Media/MediaController.Tags.cs @@ -21,7 +21,7 @@ partial class MediaController _ => mediaConfig.WritersDirectory }; - [HttpGet("{tagType}/{name}/thumbnail")] + [HttpGet("{tagType}/{name}/thumbnail"), HttpGet("{tagType}s/{name}/thumbnail")] public ActionResult GetThumbnail(TagType tagType, string name) { var filePath = Path.Combine(GetTagDirectory(tagType), $"{name}.jpg"); @@ -40,7 +40,7 @@ public ActionResult GetThumbnail(TagType tagType, string name) return File(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read), "image/jpeg"); } - [HttpPost("{tagType}/{name}/thumbnail")] + [HttpPost("{tagType}/{name}/thumbnail"), HttpPost("{tagType}s/{name}/thumbnail")] public async Task SetThumbnail(TagType tagType, string name, [FromForm] SetTagThumbnailRequest request) { if (!IsNameValid(name)) diff --git a/src/MediaBrowser.Common/Media/MediaController.cs b/src/MediaBrowser.Common/Media/MediaController.cs index cd4b3df..451b164 100644 --- a/src/MediaBrowser.Common/Media/MediaController.cs +++ b/src/MediaBrowser.Common/Media/MediaController.cs @@ -49,7 +49,7 @@ public async Task Search([FromQuery] SearchRequest request) var count = await query.CountAsync(); - query = request.ApplySortAndPagination(query); + query = await request.ApplySortAndPagination(context, query); var results = await query.ToListAsync(); diff --git a/src/MediaBrowser.Common/Media/SearchRequest.cs b/src/MediaBrowser.Common/Media/SearchRequest.cs index e84af0e..263b161 100644 --- a/src/MediaBrowser.Common/Media/SearchRequest.cs +++ b/src/MediaBrowser.Common/Media/SearchRequest.cs @@ -9,6 +9,10 @@ public record SearchRequest public string? Genres { get; init; } public string? Keywords { get; init; } public string? Producers { get; init; } + /// + /// This is a seed value used to ensure pagination is consistent across multiple random sort requests. + /// + public int Seed { get; init; } public string? Writers { get; init; } public Sort Sort { get; init; } = Sort.CreatedOn; [Range(0, int.MaxValue)] @@ -21,15 +25,40 @@ public static class SearchRequestExtensions { extension(SearchRequest request) { - public IQueryable ApplySortAndPagination(IQueryable query) + public async Task> ApplySortAndPagination(MediaDbContext context, IQueryable query) { + var isDescending = request.Descending; + + if (request.Sort == Sort.Random && context.Type == DbType.Postgres) + { + // PostgreSQL's random() is not seedable per-query, but setseed() sets the seed for the session. + // To achieve deterministic pagination, we set the seed at the start of each request. + await context.Database.ExecuteSqlRawAsync("SELECT setseed({0})", request.Seed / (double)int.MaxValue); + } + query = request.Sort switch { - Sort.Title => request.Descending ? query.OrderByDescending(m => m.Title) : query.OrderBy(m => m.Title), - Sort.CreatedOn => request.Descending ? query.OrderByDescending(m => m.CreatedOn) : query.OrderBy(m => m.CreatedOn), - Sort.Duration => request.Descending ? query.OrderByDescending(m => m.Duration) : query.OrderBy(m => m.Duration), - Sort.UserStarRating => request.Descending ? query.OrderByDescending(m => m.UserStarRating) : query.OrderBy(m => m.UserStarRating), - _ => query + Sort.Title => isDescending ? query.OrderByDescending(m => m.Title) : query.OrderBy(m => m.Title), + Sort.CreatedOn => isDescending ? query.OrderByDescending(m => m.CreatedOn) : query.OrderBy(m => m.CreatedOn), + Sort.Duration => isDescending ? query.OrderByDescending(m => m.Duration) : query.OrderBy(m => m.Duration), + Sort.UserStarRating => isDescending ? query.OrderByDescending(m => m.UserStarRating) : query.OrderBy(m => m.UserStarRating), + // Default random sort + _ => context.Type switch + { + DbType.MySql => isDescending + ? query.OrderByDescending(m => RandomDbFunctions.MySqlRand(request.Seed)) + : query.OrderBy(m => RandomDbFunctions.MySqlRand(request.Seed)), + DbType.Postgres => isDescending + ? query.OrderByDescending(m => RandomDbFunctions.PgRandom()) + : query.OrderBy(m => RandomDbFunctions.PgRandom()), + DbType.SqlServer => isDescending + ? query.OrderByDescending(m => RandomDbFunctions.SqlChecksum(request.Seed, m.Id)) + : query.OrderBy(m => RandomDbFunctions.SqlChecksum(request.Seed, m.Id)), + // Default SQLite + _ => isDescending + ? query.OrderByDescending(m => RandomDbFunctions.SqliteSeededRandom(request.Seed, m.Id)) + : query.OrderBy(m => RandomDbFunctions.SqliteSeededRandom(request.Seed, m.Id)) + } }; query = query.Skip(request.Skip); @@ -115,7 +144,8 @@ public enum Sort Title, CreatedOn, Duration, - UserStarRating + UserStarRating, + Random } [Equatable, ExcludeFromCodeCoverage(Justification = "POCO")] diff --git a/src/MediaBrowser.Common/MediaDbContext.cs b/src/MediaBrowser.Common/MediaDbContext.cs index 372dd82..68fbe72 100644 --- a/src/MediaBrowser.Common/MediaDbContext.cs +++ b/src/MediaBrowser.Common/MediaDbContext.cs @@ -2,6 +2,8 @@ namespace MediaBrowser; public abstract class MediaDbContext(DbType type, DbContextOptions options) : DbContext(options) { + public DbType Type => type; + public DbSet Casts { get; init; } public DbSet Directors { get; init; } public DbSet Genres { get; init; } @@ -18,6 +20,31 @@ public abstract class MediaDbContext(DbType type, DbContextOptions options) : Db protected override void OnModelCreating(ModelBuilder modelBuilder) { + // MySQL + modelBuilder.HasDbFunction(typeof(RandomDbFunctions) + .GetMethod(nameof(RandomDbFunctions.MySqlRand))!); + + // PostgreSQL + modelBuilder.HasDbFunction(typeof(RandomDbFunctions) + .GetMethod(nameof(RandomDbFunctions.PgRandom))!); + + // SQLite + modelBuilder.HasDbFunction(typeof(RandomDbFunctions) + .GetMethod(nameof(RandomDbFunctions.SqliteSeededRandom))!); + + // SQL Server + modelBuilder.HasDbFunction(typeof(RandomDbFunctions) + .GetMethod(nameof(RandomDbFunctions.SqlChecksum))!) + .HasTranslation(args => + new SqlFunctionExpression( + functionName: "CHECKSUM", + arguments: args, + nullable: false, + argumentsPropagateNullability: args.Select(_ => false), + type: typeof(int), + typeMapping: null + )); + modelBuilder.ApplyConfiguration(new CastEntityConfiguration()); modelBuilder.ApplyConfiguration(new DirectorEntityConfiguration()); modelBuilder.ApplyConfiguration(new GenreEntityConfiguration()); diff --git a/src/MediaBrowser.Common/Properties/GlobalUsing.cs b/src/MediaBrowser.Common/Properties/GlobalUsing.cs index 34c0a0c..33bebc7 100644 --- a/src/MediaBrowser.Common/Properties/GlobalUsing.cs +++ b/src/MediaBrowser.Common/Properties/GlobalUsing.cs @@ -35,6 +35,7 @@ global using Microsoft.AspNetCore.Mvc.Filters; global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.EntityFrameworkCore.Query.SqlExpressions; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/src/MediaBrowser.Common/RandomDbFunctions.cs b/src/MediaBrowser.Common/RandomDbFunctions.cs new file mode 100644 index 0000000..29b93cf --- /dev/null +++ b/src/MediaBrowser.Common/RandomDbFunctions.cs @@ -0,0 +1,42 @@ +namespace MediaBrowser; + +/// +/// EF Core database function mappings for seeded random ordering. +/// Functions are provider-specific — only call functions matching your configured provider. +/// +public static class RandomDbFunctions +{ + /// + /// MySQL: Returns a seeded random double. Behaviour in ORDER BY varies by version. + /// + /// RAND(@seed) + [DbFunction("RAND", IsNullable = false)] + public static double MySqlRand(int seed) => + throw new NotSupportedException("EF Core translation only."); + + /// + /// PostgreSQL: Returns a random double in [0.0, 1.0). + /// Call PgSetSeed() in a prior ExecuteSqlRawAsync to make this deterministic. + /// + /// SELECT random() + [DbFunction("random", IsNullable = false)] + public static double PgRandom() => + throw new NotSupportedException("EF Core translation only."); + + /// + /// SQLite: Seeded random order using a registered UDF. + /// Registered via SqliteUdfInterceptor — not a native SQLite function. + /// + /// seeded_random(@seed, id) + [DbFunction("seeded_random", IsNullable = false)] + public static double SqliteSeededRandom(long seed, Guid rowId) => + throw new NotSupportedException("EF Core translation only."); + + /// + /// SQL Server: Returns an integer checksum of the provided values. + /// + /// CHECKSUM(@seed, [id]) + [DbFunction("CHECKSUM", IsNullable = false)] + public static int SqlChecksum(int seed, Guid value) => + throw new NotSupportedException("EF Core translation only."); +} \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html index e2f71c5..3b71212 100644 --- a/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html +++ b/src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html @@ -1,10 +1,12 @@ @if (!isLoading) {
- -

Edit Media

+ @if (hasNavigationHistory) { + + } +

{{ mediaId === null ? 'Import Media' : 'Edit Media' }}

+ +
}
} diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts index b61c9bf..2f319e3 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.spec.ts @@ -12,6 +12,7 @@ type MetaTagType = 'cast' | 'directors' | 'genres' | 'producers' | 'writers'; interface MetaMocks { mediaService: { getAllTags: ReturnType; + setThumbnailForTag: ReturnType; }; routeParamMap$: BehaviorSubject; } @@ -33,7 +34,8 @@ async function createComponent(overrides?: { }; const mocks: MetaMocks = { mediaService: { - getAllTags: vi.fn((tagType: MetaTagType) => of(valuesByType[tagType])) + getAllTags: vi.fn((tagType: MetaTagType) => of(valuesByType[tagType])), + setThumbnailForTag: vi.fn(() => of(void 0)) }, routeParamMap$ }; @@ -255,4 +257,59 @@ describe('MetaComponent', () => { expect(clearSpy).toHaveBeenCalledTimes(1); }); + + it('opens the thumbnail file picker without triggering card navigation', async () => { + const { component } = await createComponent(); + const input = document.createElement('input'); + const clickSpy = vi.spyOn(input, 'click'); + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + + component.openThumbnailUpload({ preventDefault, stopPropagation } as unknown as MouseEvent, input); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('uploads selected thumbnail and refreshes image URL for matching member', async () => { + const { component, mocks } = await createComponent(); + const detectChangesSpy = vi.spyOn((component as any).cdr, 'detectChanges'); + const input = document.createElement('input'); + const file = new File(['thumb'], 'thumb.png', { type: 'image/png' }); + const metaMember = { + name: 'Keanu Reeves', + imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', + queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + }; + + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + component.type = 'cast'; + + await component.onThumbnailSelected({ target: input } as unknown as Event, metaMember); + + expect(mocks.mediaService.setThumbnailForTag).toHaveBeenCalledWith('cast', 'Keanu Reeves', file); + expect(metaMember.imageUrl).toContain('/api/media/cast/Keanu%20Reeves/thumbnail?t='); + expect(component.isUploading('Keanu Reeves')).toBe(false); + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + }); + + it('skips thumbnail upload when the current type is unsupported', async () => { + const { component, mocks } = await createComponent(); + const input = document.createElement('input'); + const file = new File(['thumb'], 'thumb.png', { type: 'image/png' }); + const metaMember = { + name: 'Keanu Reeves', + imageUrl: '/api/media/cast/Keanu%20Reeves/thumbnail', + queryParams: { cast: ['Keanu Reeves'], sort: SearchComponent.DEFAULT_SORT } + }; + + Object.defineProperty(input, 'files', { value: [file], configurable: true }); + component.type = 'unknown'; + + await component.onThumbnailSelected({ target: input } as unknown as Event, metaMember); + + expect(mocks.mediaService.setThumbnailForTag).not.toHaveBeenCalled(); + expect(metaMember.imageUrl).toBe('/api/media/cast/Keanu%20Reeves/thumbnail'); + }); }); diff --git a/src/MediaBrowser.Frontend/src/app/meta/meta.ts b/src/MediaBrowser.Frontend/src/app/meta/meta.ts index 7b18ffc..05220f3 100644 --- a/src/MediaBrowser.Frontend/src/app/meta/meta.ts +++ b/src/MediaBrowser.Frontend/src/app/meta/meta.ts @@ -29,6 +29,7 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { metaMembers: MetaMember[] = []; isLoading: boolean = false; type: string = ''; + uploadingMembers = new Set(); private scrollPosition: number = 0; private readonly SCROLL_KEY = '-scroll-position'; private scrollListener?: (event: Event) => void; @@ -41,6 +42,15 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { writers: 'writer' }; + private isSupportedTagType(tagType: string): tagType is MediaTagType { + return tagType in this.routePrefixMap; + } + + private getImageUrl(tagType: MediaTagType, name: string, cacheBust?: number): string { + const baseUrl = `/api/media/${this.routePrefixMap[tagType]}/${encodeURIComponent(name)}/thumbnail`; + return cacheBust ? `${baseUrl}?t=${cacheBust}` : baseUrl; + } + async ngOnInit(): Promise { this.routeSubscription = this.route.paramMap.subscribe(async (params) => { const newType = params.get('type')?.toLowerCase() ?? ''; @@ -108,18 +118,16 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { try { - let routePreFix = ''; let results: string[] = []; - if (this.type in this.routePrefixMap) { + if (this.isSupportedTagType(this.type)) { const tagType = this.type as MediaTagType; results = await firstValueFrom(this.mediaService.getAllTags(tagType)); - routePreFix = this.routePrefixMap[tagType]; } this.metaMembers = results.map(name => ({ name, - imageUrl: `/api/media/${encodeURIComponent(routePreFix)}/${encodeURIComponent(name)}/thumbnail`, + imageUrl: this.getImageUrl(this.type as MediaTagType, name), queryParams: { [this.type]: [name], sort: SearchComponent.DEFAULT_SORT } })); @@ -141,4 +149,37 @@ export class MetaComponent implements OnInit, AfterViewInit, OnDestroy { clearPagePositionState(): void { SearchComponent.clearPagePositionState(); } + + isUploading(name: string): boolean { + return this.uploadingMembers.has(name); + } + + openThumbnailUpload(event: MouseEvent, input: HTMLInputElement): void { + event.preventDefault(); + event.stopPropagation(); + input.click(); + } + + async onThumbnailSelected(event: Event, metaMember: MetaMember): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + + if (!file || !this.isSupportedTagType(this.type)) { + return; + } + + const tagType = this.type; + this.uploadingMembers.add(metaMember.name); + + try { + await firstValueFrom(this.mediaService.setThumbnailForTag(tagType, metaMember.name, file)); + metaMember.imageUrl = this.getImageUrl(tagType, metaMember.name, Date.now()); + } catch (error) { + console.error('Thumbnail upload error:', error); + } finally { + this.uploadingMembers.delete(metaMember.name); + input.value = ''; + this.cdr.detectChanges(); + } + } } \ No newline at end of file diff --git a/src/MediaBrowser.Frontend/src/app/search/search.ts b/src/MediaBrowser.Frontend/src/app/search/search.ts index c3afada..dece143 100644 --- a/src/MediaBrowser.Frontend/src/app/search/search.ts +++ b/src/MediaBrowser.Frontend/src/app/search/search.ts @@ -20,7 +20,7 @@ interface CacheData { * - keywords: string search term * - cast: array of cast member names * - genres: array of genre names - * - sort: sort field ('title' | 'createdOn' | 'duration' | 'userStarRating') + * - sort: sort field ('title' | 'createdOn' | 'duration' | 'userStarRating' | 'random') * - descending: boolean for sort direction * * Example URLs: @@ -43,7 +43,8 @@ export class SearchComponent implements OnInit { { value: 'title', label: 'Title' }, { value: 'createdOn', label: 'Created On' }, { value: 'duration', label: 'Duration' }, - { value: 'userStarRating', label: 'Rating' } + { value: 'userStarRating', label: 'Rating' }, + { value: 'random', label: 'Random' } ] as const; static readonly DEFAULT_SORT = 'title'; @@ -55,7 +56,7 @@ export class SearchComponent implements OnInit { genres: string[] = []; keywords: string = ''; producers: string[] = []; - sort: 'title' | 'createdOn' | 'duration' | 'userStarRating' = SearchComponent.DEFAULT_SORT; + sort: 'title' | 'createdOn' | 'duration' | 'userStarRating' | 'random' = SearchComponent.DEFAULT_SORT; writers: string[] = []; // UI state @@ -338,7 +339,7 @@ export class SearchComponent implements OnInit { this.onSearch(); } - selectSortOption(sortValue: 'title' | 'createdOn' | 'duration' | 'userStarRating'): void { + selectSortOption(sortValue: 'title' | 'createdOn' | 'duration' | 'userStarRating' | 'random'): void { this.sort = sortValue; this.showSortModal = false; this.onSearch(); diff --git a/src/MediaBrowser.Frontend/src/app/services/media.service.ts b/src/MediaBrowser.Frontend/src/app/services/media.service.ts index 9086e28..ce1b0e2 100644 --- a/src/MediaBrowser.Frontend/src/app/services/media.service.ts +++ b/src/MediaBrowser.Frontend/src/app/services/media.service.ts @@ -9,7 +9,8 @@ export interface SearchMediaRequest { genres?: string[]; keywords?: string; producers?: string[]; - sort: 'title' | 'createdOn' | 'duration' | 'userStarRating'; + seed?: number; + sort: 'title' | 'createdOn' | 'duration' | 'userStarRating' | 'random'; skip?: number; take?: number; writers?: string[]; @@ -85,7 +86,9 @@ export class MediaService { return this.apiService.put(`/media/${id}`, request); } + private readonly seed: number = (Math.random() * 0x100000000 | 0); search(request: SearchMediaRequest): Observable { + request.seed ??= this.seed; return this.apiService.get('/media/search', request); } @@ -93,6 +96,12 @@ export class MediaService { return this.apiService.get(`/media/${tagType}`); } + setThumbnailForTag(tagType: MediaTagType, name: string, file: File): Observable { + const formData = new FormData(); + formData.append('thumbnail', file, file.name); + return this.apiService.post(`/media/${tagType}/${name}/thumbnail`, formData); + } + updateFanartThumbnail(id: string, request: UpdateThumbnailRequest): Observable { return this.apiService.post(`/media/${id}/file/thumbnail-fanart`, request); } diff --git a/src/MediaBrowser.Tests/Media/SearchRequestExtensionsUnitTests.cs b/src/MediaBrowser.Tests/Media/SearchRequestExtensionsUnitTests.cs index 0d53ee9..3928e2d 100644 --- a/src/MediaBrowser.Tests/Media/SearchRequestExtensionsUnitTests.cs +++ b/src/MediaBrowser.Tests/Media/SearchRequestExtensionsUnitTests.cs @@ -2,9 +2,13 @@ namespace MediaBrowser.Media; public class SearchRequestExtensionsUnitTests { - IQueryable GetSampleMediaEntities() => new List + /* NOTE: sorting and keyword filtering logic is tested in the SearchRequestExtensionsIntegrationTests, + * EF functions and are not unit testable. + */ + IEnumerable GetSampleMediaEntities() { - new() + var mediaId1 = Guid.NewGuid(); + yield return new() { Id = Guid.NewGuid(), Path = "/a", @@ -24,21 +28,66 @@ public class SearchRequestExtensionsUnitTests MtimeMs = 2, CreatedOn = new DateTime(2020, 1, 1), UpdatedOn = new DateTime(2020, 1, 2), - Ffprobe = null!, - Cast = new List { new() - { Id = 1, MediaId = Guid.NewGuid(), Name = "John", Media = null! } }, - Directors = new List { new() - { Id = 1, MediaId = Guid.NewGuid(), Name = "Jane", Media = null! } }, - Genres = new List { new() - { Id = 1, MediaId = Guid.NewGuid(), Name = "Action", Media = null! } }, - Producers = new List { new() - { Id = 1, MediaId = Guid.NewGuid(), Name = "Producer1", Media = null! } }, - Writers = new List { new() - { Id = 1, MediaId = Guid.NewGuid(), Name = "Writer1", Media = null! } } - }, - new() + Ffprobe = new() + { + Streams = [], + Format = null + }, + Cast = new List + { + new() + { + Id = 0, + MediaId = mediaId1, + Name = "John", + Media = null! + } + }, + Directors = new List + { + new() + { + Id = 0, + MediaId = mediaId1, + Name = "Jane", + Media = null! + } + }, + Genres = new List + { + new() + { + Id = 0, + MediaId = mediaId1, + Name = "Action", + Media = null! + } + }, + Producers = new List + { + new() + { + Id = 0, + MediaId = mediaId1, + Name = "Producer1", + Media = null! + } + }, + Writers = new List + { + new() + { + Id = 0, + MediaId = mediaId1, + Name = "Writer1", + Media = null! + } + } + }; + var mediaId2 = Guid.NewGuid(); + yield return new() { - Id = Guid.NewGuid(), + Id = mediaId2, Path = "/b", Title = "B", OriginalTitle = "OrigB", @@ -56,106 +105,235 @@ public class SearchRequestExtensionsUnitTests MtimeMs = 4, CreatedOn = new DateTime(2021, 1, 1), UpdatedOn = new DateTime(2021, 1, 2), - Ffprobe = null!, - Cast = new List { new() - { Id = 2, MediaId = Guid.NewGuid(), Name = "Alice", Media = null! } }, - Directors = new List { new() - { Id = 2, MediaId = Guid.NewGuid(), Name = "Bob", Media = null! } }, - Genres = new List { new() - { Id = 2, MediaId = Guid.NewGuid(), Name = "Drama", Media = null! } }, - Producers = new List { new() - { Id = 2, MediaId = Guid.NewGuid(), Name = "Producer2", Media = null! } }, - Writers = new List { new() - { Id = 2, MediaId = Guid.NewGuid(), Name = "Writer2", Media = null! } } - } - }.AsQueryable(); + Ffprobe = new() + { + Streams = [], + Format = null + }, + Cast = new List + { + new() + { + Id = 0, + MediaId = mediaId2, + Name = "Alice", + Media = null! + } + }, + Directors = new List + { + new() + { + Id = 0, + MediaId = mediaId2, + Name = "Bob", + Media = null! + } + }, + Genres = new List + { + new() + { + Id = 0, + MediaId = mediaId2, + Name = "Drama", + Media = null! + } + }, + Producers = new List + { + new() + { + Id = 0, + MediaId = mediaId2, + Name = "Producer2", + Media = null! + } + }, + Writers = new List + { + new() + { + Id = 0, + MediaId = mediaId2, + Name = "Writer2", + Media = null! + } + } + }; + } - [Test] - public void ApplySortAndPaginationSortsByTitleAscending() + [Test(Description = "The search features should work with every SQL DB type."), + TestCase(DbType.MySql), + TestCase(DbType.Postgres), + TestCase(DbType.Sqlite), + TestCase(DbType.SqlServer)] + public async Task Test(DbType dbType) { - var request = new SearchRequest { Sort = Sort.Title, Descending = false, Skip = 0, Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.ApplySortAndPagination(query).ToList(); - result[0].Title.ShouldBe("A"); - result[1].Title.ShouldBe("B"); + await using var factory = new MediaBrowserWebApplicationFactory(dbType); + + await factory.StartServerAsync(); + + await InsertTestData(factory); + + using var scope = factory.Services.CreateScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + + await ApplySortAndPaginationSkipAndTake(db); + await ApplySortAndPaginationSkipAndTake(db, Sort.Title, false); + await ApplySortAndPaginationSkipAndTake(db, Sort.Title, true); + await ApplySortAndPaginationSkipAndTake(db, Sort.CreatedOn, false); + await ApplySortAndPaginationSkipAndTake(db, Sort.CreatedOn, true); + await ApplySortAndPaginationSkipAndTake(db, Sort.Duration, false); + await ApplySortAndPaginationSkipAndTake(db, Sort.Duration, true); + await ApplySortAndPaginationSkipAndTake(db, Sort.UserStarRating, false); + await ApplySortAndPaginationSkipAndTake(db, Sort.UserStarRating, true); + for (var i = 0; i < 10; i++) + { + await ApplySortAndPaginationSkipAndTakeRandom(db, seed: 123 * i + 1); + } + await CreateQueryFiltersByCast(db.MediaJoined); + await CreateQueryFiltersByDirectors(db.MediaJoined); + await CreateQueryFiltersByGenres(db.MediaJoined); + await CreateQueryFiltersByProducers(db.MediaJoined); + await CreateQueryFiltersByWriters(db.MediaJoined); + await CreateQueryNoFiltersReturnsAll(db.MediaJoined); } - [Test] - public void ApplySortAndPaginationSortsByTitleDescending() + async Task InsertTestData(MediaBrowserWebApplicationFactory factory) { - var request = new SearchRequest { Sort = Sort.Title, Descending = true, Skip = 0, Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.ApplySortAndPagination(query).ToList(); - result[0].Title.ShouldBe("B"); - result[1].Title.ShouldBe("A"); + using var scope = factory.Services.CreateScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + await db.Media.AddRangeAsync(GetSampleMediaEntities()); + await db.SaveChangesAsync(); } - [Test] - public void ApplySortAndPaginationSkipAndTake() + async Task ApplySortAndPaginationSkipAndTake(MediaDbContext db) { var request = new SearchRequest { Sort = Sort.Title, Descending = false, Skip = 1, Take = 1 }; - var query = GetSampleMediaEntities(); - var result = request.ApplySortAndPagination(query).ToList(); + var result = await (await request.ApplySortAndPagination(db, db.MediaJoined)).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("B"); } - [Test] - public void CreateQueryFiltersByCast() + async Task ApplySortAndPaginationSkipAndTake(MediaDbContext db, Sort sort, bool descending) + { + var request = new SearchRequest { Sort = sort, Descending = descending, Skip = 0, Take = null }; + var result = await (await request.ApplySortAndPagination(db, db.MediaJoined)).ToListAsync(); + result.Count.ShouldBe(2); + + switch (sort) + { + case Sort.Title: + if (descending) + { + result[0].Title.ShouldBe("B"); + result[1].Title.ShouldBe("A"); + } + else + { + result[0].Title.ShouldBe("A"); + result[1].Title.ShouldBe("B"); + } + break; + case Sort.CreatedOn: + if (descending) + { + result[0].CreatedOn.ShouldNotBeNull().Year.ShouldBe(2021); + result[1].CreatedOn.ShouldNotBeNull().Year.ShouldBe(2020); + } + else + { + result[0].CreatedOn.ShouldNotBeNull().Year.ShouldBe(2020); + result[1].CreatedOn.ShouldNotBeNull().Year.ShouldBe(2021); + } + break; + case Sort.Duration: + if (descending) + { + result[0].Duration.ShouldNotBeNull().ShouldBe(20); + result[1].Duration.ShouldNotBeNull().ShouldBe(10); + } + else + { + result[0].Duration.ShouldNotBeNull().ShouldBe(10); + result[1].Duration.ShouldNotBeNull().ShouldBe(20); + } + break; + case Sort.UserStarRating: + if (descending) + { + result[0].UserStarRating.ShouldNotBeNull().ShouldBe(5); + result[1].UserStarRating.ShouldNotBeNull().ShouldBe(3); + } + else + { + result[0].UserStarRating.ShouldNotBeNull().ShouldBe(3); + result[1].UserStarRating.ShouldNotBeNull().ShouldBe(5); + } + break; + } + } + + async Task ApplySortAndPaginationSkipAndTakeRandom(MediaDbContext db, int seed) + { + // With a fixed seed, the random order should be consistent across multiple calls, even with different providers that implement the random sorting differently. + var ascendingRequest = new SearchRequest { Sort = Sort.Random, Descending = false, Seed = seed, Skip = 0, Take = null }; + var ascendingResults = await (await ascendingRequest.ApplySortAndPagination(db, db.MediaJoined)).ToListAsync(); + ascendingResults.Count.ShouldBe(2); + + var descendingRequest = new SearchRequest { Sort = Sort.Random, Descending = true, Seed = seed, Skip = 0, Take = null }; + var descendingResults = await (await descendingRequest.ApplySortAndPagination(db, db.MediaJoined)).ToListAsync(); + descendingResults.Count.ShouldBe(2); + + ascendingResults[0].Id.ShouldBe(descendingResults[1].Id); + ascendingResults[1].Id.ShouldBe(descendingResults[0].Id); + } + + async Task CreateQueryFiltersByCast(IQueryable query) { var request = new SearchRequest { Cast = "John", Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("A"); } - [Test] - public void CreateQueryFiltersByDirectors() + async Task CreateQueryFiltersByDirectors(IQueryable query) { var request = new SearchRequest { Directors = "Bob", Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("B"); } - [Test] - public void CreateQueryFiltersByGenres() + async Task CreateQueryFiltersByGenres(IQueryable query) { var request = new SearchRequest { Genres = "Drama", Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("B"); } - [Test] - public void CreateQueryFiltersByProducers() + async Task CreateQueryFiltersByProducers(IQueryable query) { var request = new SearchRequest { Producers = "Producer2", Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("B"); } - [Test] - public void CreateQueryFiltersByWriters() + async Task CreateQueryFiltersByWriters(IQueryable query) { var request = new SearchRequest { Writers = "Writer1", Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(1); result[0].Title.ShouldBe("A"); } - [Test] - public void CreateQueryNoFiltersReturnsAll() + async Task CreateQueryNoFiltersReturnsAll(IQueryable query) { var request = new SearchRequest { Take = 2 }; - var query = GetSampleMediaEntities(); - var result = request.CreateQuery(query).ToList(); + var result = await request.CreateQuery(query).ToListAsync(); result.Count.ShouldBe(2); } } \ No newline at end of file diff --git a/src/MediaBrowser.Tests/MediaBrowserWebApplicationFactory.cs b/src/MediaBrowser.Tests/MediaBrowserWebApplicationFactory.cs index abd7315..e326952 100644 --- a/src/MediaBrowser.Tests/MediaBrowserWebApplicationFactory.cs +++ b/src/MediaBrowser.Tests/MediaBrowserWebApplicationFactory.cs @@ -1,3 +1,5 @@ +using System.Data.Common; + namespace MediaBrowser; public class MediaBrowserWebApplicationFactory : WebApplicationFactory @@ -47,7 +49,7 @@ public MediaBrowserWebApplicationFactory(DbType dbType = DbType.Sqlite, TimeSpan Directory.CreateDirectory(tempDirectory); var sqlLiteFile = Path.Combine(tempDirectory, $"media-browser-{TestContext.CurrentContext.Test.ID}.db"); - var sqlLiteConnectionString = $"Data Source={sqlLiteFile}"; + var sqlLiteConnectionString = $"Data Source={sqlLiteFile};Pooling=False"; CastDirectory = Path.Combine(tempDirectory, "cast"); Directory.CreateDirectory(CastDirectory); @@ -88,6 +90,8 @@ public MediaBrowserWebApplicationFactory(DbType dbType = DbType.Sqlite, TimeSpan }); } public CancellationTokenSource CancellationTokenSource { get; } + + public DbConnection? Connection { get; private set; } public DbType DbType { get; } public List ConfigurationFiles { get; } public string CastDirectory { get; } @@ -113,15 +117,21 @@ public IConfiguration GetConfiguration() public async Task StartServerAsync() { - await this.CleanDatabase(cancellationToken: CancellationTokenSource.Token); + Connection = await this.CleanDatabase(cancellationToken: CancellationTokenSource.Token); StartServer(); await Installer.OnStartup(Services, CancellationTokenSource.Token); } - protected override IHostBuilder CreateHostBuilder() => Installer.CreateHostBuilder([], ConfigurationFiles); + protected override IHostBuilder CreateHostBuilder() => Installer.CreateHostBuilder([], ConfigurationFiles, Connection); public string GetJwtForTestUser(UserReadModel? user = null) { var userConfig = Services.GetRequiredService(); return userConfig.GetJwt(user?.Id ?? Guid.NewGuid(), user?.Username ?? "testUser").Jwt; } + protected override void Dispose(bool disposing) + { + Connection?.Close(); + Connection?.Dispose(); + base.Dispose(disposing); + } } \ No newline at end of file diff --git a/src/MediaBrowser.Tests/TestDatabaseCleaner.cs b/src/MediaBrowser.Tests/TestDatabaseCleaner.cs index 8f91828..b5c4046 100644 --- a/src/MediaBrowser.Tests/TestDatabaseCleaner.cs +++ b/src/MediaBrowser.Tests/TestDatabaseCleaner.cs @@ -1,8 +1,11 @@ +using System.Data.Common; +using MediaBrowser.Media; + namespace MediaBrowser; public static partial class TestDatabaseCleaner { - public async static Task CleanDatabase(this MediaBrowserWebApplicationFactory factory, CancellationToken cancellationToken = default) + public async static Task CleanDatabase(this MediaBrowserWebApplicationFactory factory, CancellationToken cancellationToken = default) { var config = factory.GetConfiguration(); @@ -28,21 +31,18 @@ public async static Task CleanDatabase(this MediaBrowserWebApplicationFactory fa switch (factory.DbType) { case DbType.MySql: - await CleanMySql(dbConnectionString, dbName, cancellationToken); - break; + return await CleanMySql(dbConnectionString, dbName, cancellationToken); case DbType.Postgres: - await CleanPostgres(dbConnectionString, dbName, cancellationToken); - break; + return await CleanPostgres(dbConnectionString, dbName, cancellationToken); + default: case DbType.Sqlite: - await CleanSqlite(connectionString, cancellationToken); - break; + return await CleanSqlite(connectionString, cancellationToken); case DbType.SqlServer: - await CleanSqlServer(dbConnectionString, dbName, cancellationToken); - break; + return await CleanSqlServer(dbConnectionString, dbName, cancellationToken); } } - async static private Task CleanMySql(string connectionString, string dbName, CancellationToken cancellationToken = default) + async static private Task CleanMySql(string connectionString, string dbName, CancellationToken cancellationToken = default) { await using var connection = new MySqlConnection(connectionString); await connection.OpenAsync(cancellationToken); @@ -58,16 +58,30 @@ async static private Task CleanMySql(string connectionString, string dbName, Can command.CommandText = $"CREATE DATABASE {dbName}; "; await command.ExecuteNonQueryAsync(cancellationToken); } + + return new($"{connectionString};Database={dbName};"); } - async static private Task CleanPostgres(string connectionString, string dbName, CancellationToken cancellationToken = default) + async static private Task CleanPostgres(string connectionString, string dbName, CancellationToken cancellationToken = default) { + // Clear all Npgsql connection pools + NpgsqlConnection.ClearAllPools(); + await using var connection = new NpgsqlConnection(connectionString); await connection.OpenAsync(cancellationToken); + await using (var terminateCmd = connection.CreateCommand()) + { + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{dbName}' AND pid <> pg_backend_pid();"; + await terminateCmd.ExecuteNonQueryAsync(cancellationToken); + } + await using (var command = connection.CreateCommand()) { - command.CommandText = $"DROP DATABASE {dbName};"; + command.CommandText = $"DROP DATABASE IF EXISTS {dbName};"; await command.ExecuteNonQueryAsync(cancellationToken); } @@ -76,14 +90,17 @@ async static private Task CleanPostgres(string connectionString, string dbName, command.CommandText = $"CREATE DATABASE {dbName}; "; await command.ExecuteNonQueryAsync(cancellationToken); } + + return new($"{connectionString};Database={dbName};"); } - async static private Task CleanSqlite(string connectionString, CancellationToken cancellationToken = default) + async static private Task CleanSqlite(string connectionString, CancellationToken cancellationToken = default) { // NOTE: For SQLite, we can't drop the database since it's just a file, // so instead we need to drop all tables in the database to clean it. - await using var connection = new SqliteConnection(connectionString); + var connection = new SqliteConnection(connectionString); await connection.OpenAsync(cancellationToken); + SqliteUdfInterceptor.RegisterUdfs(connection); var tables = new List(); await using (var command = connection.CreateCommand()) @@ -102,24 +119,37 @@ async static private Task CleanSqlite(string connectionString, CancellationToken command.CommandText = $"DROP TABLE IF EXISTS \"{table}\""; await command.ExecuteNonQueryAsync(cancellationToken); } + + return connection; } - async static private Task CleanSqlServer(string connectionString, string dbName, CancellationToken cancellationToken = default) + async static private Task CleanSqlServer(string connectionString, string dbName, CancellationToken cancellationToken = default) { + // Clear all SqlClient connection pools + SqlConnection.ClearAllPools(); + await using var connection = new SqlConnection(connectionString); await connection.OpenAsync(cancellationToken); await using (var command = connection.CreateCommand()) { - command.CommandText = $"DROP DATABASE IF EXISTS {dbName};"; + command.CommandText = $"ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;"; + try { await command.ExecuteNonQueryAsync(cancellationToken); } catch { /* ignore if db doesn't exist */ } + } + + await using (var command = connection.CreateCommand()) + { + command.CommandText = $"DROP DATABASE IF EXISTS [{dbName}];"; await command.ExecuteNonQueryAsync(cancellationToken); } await using (var command = connection.CreateCommand()) { - command.CommandText = $"CREATE DATABASE {dbName}; "; + command.CommandText = $"CREATE DATABASE [{dbName}]; "; await command.ExecuteNonQueryAsync(cancellationToken); } + + return new($"{connectionString};Database={dbName};"); } /// diff --git a/src/MediaBrowser/Installer.cs b/src/MediaBrowser/Installer.cs index 6760001..1a17673 100644 --- a/src/MediaBrowser/Installer.cs +++ b/src/MediaBrowser/Installer.cs @@ -5,7 +5,7 @@ public class Installer public const string Version = "v1"; public const string CliArgsKey = "CliArgs", TestConfigsKey = "TestConfigs"; - public static IHostBuilder CreateHostBuilder(string[] args, IReadOnlyList configs) + public static IHostBuilder CreateHostBuilder(string[] args, IReadOnlyList configs, DbConnection? connection = null) { var builder = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration(configurationBuilder => @@ -15,7 +15,8 @@ public static IHostBuilder CreateHostBuilder(string[] args, IReadOnlyList + MediaInstaller.ConfigureServices(context, services, connection)) .ConfigureServices(ImportInstaller.ConfigureServices) .ConfigureServices(UserInstaller.ConfigureServices) .ConfigureWebHostDefaults(webBuilder => webBuilder.Configure(ConfigureApp)); diff --git a/src/MediaBrowser/Media/MediaInstaller.cs b/src/MediaBrowser/Media/MediaInstaller.cs index d2f1904..b7e0a64 100644 --- a/src/MediaBrowser/Media/MediaInstaller.cs +++ b/src/MediaBrowser/Media/MediaInstaller.cs @@ -1,8 +1,12 @@ +using Microsoft.Data.SqlClient; +using MySqlConnector; +using Npgsql; + namespace MediaBrowser.Media; static class MediaInstaller { - public static void ConfigureServices(HostBuilderContext context, IServiceCollection services) + public static void ConfigureServices(HostBuilderContext context, IServiceCollection services, DbConnection? connection) { var mediaConfig = new MediaConfig(context.Configuration); services.AddSingleton(mediaConfig); @@ -13,21 +17,25 @@ public static void ConfigureServices(HostBuilderContext context, IServiceCollect switch (dbConfig.DbType) { case DbType.MySql: + connection ??= new MySqlConnection(dbConfig.MySqlConnectionString); services.AddDbContext(options => - options.UseMySql(dbConfig.MySqlConnectionString, ServerVersion.AutoDetect(dbConfig.MySqlConnectionString))); + options.UseMySql(connection, ServerVersion.AutoDetect((MySqlConnection)connection))); break; case DbType.Postgres: + connection ??= new NpgsqlConnection(dbConfig.PostgresConnectionString); services.AddDbContext(options => - options.UseNpgsql(dbConfig.PostgresConnectionString)); + options.UseNpgsql(connection)); break; default: case DbType.Sqlite: + connection ??= new SqliteConnection(dbConfig.SqliteConnectionString); services.AddDbContext(options => - options.UseSqlite(dbConfig.SqliteConnectionString)); + options.UseSqlite(connection)); break; case DbType.SqlServer: + connection ??= new SqlConnection(dbConfig.SqlServerConnectionString); services.AddDbContext(options => - options.UseSqlServer(dbConfig.SqlServerConnectionString)); + options.UseSqlServer(connection)); break; } } diff --git a/src/MediaBrowser/Media/SqliteUdfInterceptor.cs b/src/MediaBrowser/Media/SqliteUdfInterceptor.cs new file mode 100644 index 0000000..4efa98a --- /dev/null +++ b/src/MediaBrowser/Media/SqliteUdfInterceptor.cs @@ -0,0 +1,21 @@ +namespace MediaBrowser.Media; + +public class SqliteUdfInterceptor : DbConnectionInterceptor +{ + public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) => RegisterUdfs((SqliteConnection)connection); + + public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct = default) + { + RegisterUdfs((SqliteConnection)connection); + return Task.CompletedTask; + } + + public static void RegisterUdfs(SqliteConnection connection) + { + connection.CreateFunction("seeded_random", (long seed, Guid id) => + { + var rng = new Random((int)(seed ^ id.GetHashCode())); + return rng.NextDouble(); + }); + } +} \ No newline at end of file diff --git a/src/MediaBrowser/Properties/GlobalUsings.cs b/src/MediaBrowser/Properties/GlobalUsings.cs index 02704d3..16c97b7 100644 --- a/src/MediaBrowser/Properties/GlobalUsings.cs +++ b/src/MediaBrowser/Properties/GlobalUsings.cs @@ -1,3 +1,4 @@ +global using System.Data.Common; global using System.Diagnostics.CodeAnalysis; global using System.Text; global using System.Text.Json.Nodes; @@ -6,4 +7,6 @@ global using MediaBrowser.Users; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Mvc; -global using Microsoft.EntityFrameworkCore; \ No newline at end of file +global using Microsoft.Data.Sqlite; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Diagnostics; \ No newline at end of file