Skip to content
Merged
4 changes: 2 additions & 2 deletions src/MediaBrowser.Common/Media/MediaController.Tags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<ActionResult> SetThumbnail(TagType tagType, string name, [FromForm] SetTagThumbnailRequest request)
{
if (!IsNameValid(name))
Expand Down
2 changes: 1 addition & 1 deletion src/MediaBrowser.Common/Media/MediaController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task<SearchResponse> Search([FromQuery] SearchRequest request)

var count = await query.CountAsync();

query = request.ApplySortAndPagination(query);
query = await request.ApplySortAndPagination(context, query);

var results = await query.ToListAsync();

Expand Down
44 changes: 37 additions & 7 deletions src/MediaBrowser.Common/Media/SearchRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ public record SearchRequest
public string? Genres { get; init; }
public string? Keywords { get; init; }
public string? Producers { get; init; }
/// <summary>
/// This is a seed value used to ensure pagination is consistent across multiple random sort requests.
/// </summary>
public int Seed { get; init; }
public string? Writers { get; init; }
public Sort Sort { get; init; } = Sort.CreatedOn;
[Range(0, int.MaxValue)]
Expand All @@ -21,15 +25,40 @@ public static class SearchRequestExtensions
{
extension(SearchRequest request)
{
public IQueryable<MediaEntity> ApplySortAndPagination(IQueryable<MediaEntity> query)
public async Task<IQueryable<MediaEntity>> ApplySortAndPagination(MediaDbContext context, IQueryable<MediaEntity> 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);
Expand Down Expand Up @@ -115,7 +144,8 @@ public enum Sort
Title,
CreatedOn,
Duration,
UserStarRating
UserStarRating,
Random
}

[Equatable, ExcludeFromCodeCoverage(Justification = "POCO")]
Expand Down
27 changes: 27 additions & 0 deletions src/MediaBrowser.Common/MediaDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ namespace MediaBrowser;

public abstract class MediaDbContext(DbType type, DbContextOptions options) : DbContext(options)
{
public DbType Type => type;

public DbSet<CastEntity> Casts { get; init; }
public DbSet<DirectorEntity> Directors { get; init; }
public DbSet<GenreEntity> Genres { get; init; }
Expand All @@ -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());
Expand Down
1 change: 1 addition & 0 deletions src/MediaBrowser.Common/Properties/GlobalUsing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions src/MediaBrowser.Common/RandomDbFunctions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace MediaBrowser;

/// <summary>
/// EF Core database function mappings for seeded random ordering.
/// Functions are provider-specific — only call functions matching your configured provider.
/// </summary>
public static class RandomDbFunctions
{
/// <summary>
/// MySQL: Returns a seeded random double. Behaviour in ORDER BY varies by version.
/// </summary>
/// <example>RAND(@seed)</example>
[DbFunction("RAND", IsNullable = false)]
public static double MySqlRand(int seed) =>
throw new NotSupportedException("EF Core translation only.");

/// <summary>
/// PostgreSQL: Returns a random double in [0.0, 1.0).
/// Call PgSetSeed() in a prior ExecuteSqlRawAsync to make this deterministic.
/// </summary>
/// <example>SELECT random()</example>
[DbFunction("random", IsNullable = false)]
public static double PgRandom() =>
throw new NotSupportedException("EF Core translation only.");

/// <summary>
/// SQLite: Seeded random order using a registered UDF.
/// Registered via SqliteUdfInterceptor — not a native SQLite function.
/// </summary>
/// <example>seeded_random(@seed, id)</example>
[DbFunction("seeded_random", IsNullable = false)]
public static double SqliteSeededRandom(long seed, Guid rowId) =>
throw new NotSupportedException("EF Core translation only.");

/// <summary>
/// SQL Server: Returns an integer checksum of the provided values.
/// </summary>
/// <example>CHECKSUM(@seed, [id])</example>
[DbFunction("CHECKSUM", IsNullable = false)]
public static int SqlChecksum(int seed, Guid value) =>
throw new NotSupportedException("EF Core translation only.");
}
10 changes: 6 additions & 4 deletions src/MediaBrowser.Frontend/src/app/media-editor/media-editor.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
@if (!isLoading) {
<div class="editor-container">
<div class="editor-header">
<button class="back-button" (click)="cancel()">
<i class="fa-solid fa-chevron-left"></i>
</button>
<h1>Edit Media</h1>
@if (hasNavigationHistory) {
<button class="back-button" (click)="cancel()">
<i class="fa-solid fa-chevron-left"></i>
</button>
}
<h1>{{ mediaId === null ? 'Import Media' : 'Edit Media' }}</h1>
<div class="header-actions">
<button class="cancel-button" (click)="cancel()" [disabled]="isSaving">
Cancel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function createMedia(overrides: Partial<MediaReadModel> = {}): MediaReadModel {

describe('MediaEditorComponent', () => {
let routeParams: Record<string, string | null>;
let currentNavigation: { extras?: { state?: Record<string, unknown> } } | null;
let currentNavigation: { extras?: { state?: Record<string, unknown> }; previousNavigation?: unknown } | null;

let mediaServiceMock: {
get: ReturnType<typeof vi.fn>;
Expand All @@ -67,7 +67,10 @@ describe('MediaEditorComponent', () => {

beforeEach(async () => {
routeParams = {};
currentNavigation = null;
currentNavigation = {
extras: { state: {} },
previousNavigation: {}
};

mediaServiceMock = {
get: vi.fn().mockReturnValue(of(createMedia())),
Expand Down Expand Up @@ -103,7 +106,8 @@ describe('MediaEditorComponent', () => {
{
provide: Router,
useValue: {
currentNavigation: vi.fn(() => currentNavigation)
currentNavigation: vi.fn(() => currentNavigation),
navigate: vi.fn().mockResolvedValue(true)
}
},
{
Expand Down
16 changes: 14 additions & 2 deletions src/MediaBrowser.Frontend/src/app/media-editor/media-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export class MediaEditorComponent implements OnInit {
this.navigation = this.router.currentNavigation();
}

get hasNavigationHistory(): boolean {
return this.navigation?.previousNavigation != null;
}

filename: string | null = null;
isCreatingThumbnail: boolean = false;
isLoading: boolean = false;
Expand Down Expand Up @@ -146,7 +150,11 @@ export class MediaEditorComponent implements OnInit {
}
SearchComponent.clearCachedResults();
PeopleSectionComponent.clearCacheIfStale(this.getPeopleData());
this.location.back();
if (this.hasNavigationHistory) {
this.location.back();
} else if (this.filename) {
this.router.navigate(['/search'], { queryParams: { sort: SearchComponent.DEFAULT_SORT } });
}
} catch (error) {
console.error('Error saving media changes:', error);
} finally {
Expand All @@ -156,7 +164,11 @@ export class MediaEditorComponent implements OnInit {
}

cancel(): void {
this.location.back();
if (this.hasNavigationHistory) {
this.location.back();
} else {
this.router.navigate(['/search'], { queryParams: { sort: SearchComponent.DEFAULT_SORT } });
}
}

// Helper methods for formatted display
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, inject, Input, OnInit, Output, QueryList, ViewChildren } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { MediaService } from '../../services';
Expand All @@ -22,6 +22,12 @@ export interface PeopleData {
export class PeopleSectionComponent implements OnInit {
private mediaService: MediaService = inject(MediaService);

@ViewChildren('castInput') private castInputs!: QueryList<TypeaheadInputComponent>;
@ViewChildren('directorsInput') private directorsInputs!: QueryList<TypeaheadInputComponent>;
@ViewChildren('genresInput') private genresInputs!: QueryList<TypeaheadInputComponent>;
@ViewChildren('producersInput') private producersInputs!: QueryList<TypeaheadInputComponent>;
@ViewChildren('writersInput') private writersInputs!: QueryList<TypeaheadInputComponent>;

@Input() peopleData: PeopleData = {
cast: [],
directors: [],
Expand Down Expand Up @@ -53,6 +59,27 @@ export class PeopleSectionComponent implements OnInit {
addArrayItem(arrayName: keyof PeopleData): void {
this.peopleData[arrayName].push('');
this.onPeopleChange();
this.focusNewlyAddedInput(arrayName);
}

private focusNewlyAddedInput(arrayName: keyof PeopleData): void {
setTimeout(() => {
const inputsByType: Record<keyof PeopleData, QueryList<TypeaheadInputComponent>> = {
cast: this.castInputs,
directors: this.directorsInputs,
genres: this.genresInputs,
producers: this.producersInputs,
writers: this.writersInputs
};

const inputList = inputsByType[arrayName];
if (!inputList) {
return;
}

const newlyAddedInput = inputList.toArray().at(-1);
newlyAddedInput?.focus();
});
}

async ngOnInit(): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ <h3>People</h3>
@for (member of peopleData.cast; track $index; let i = $index) {
<div class="array-item">
<app-typeahead-input
#castInput
[(ngModel)]="peopleData.cast[i]"
[suggestions]="getCastSuggestions()"
placeholder="Cast member name"
Expand Down Expand Up @@ -38,6 +39,7 @@ <h3>People</h3>
@for (director of peopleData.directors; track $index; let i = $index) {
<div class="array-item">
<app-typeahead-input
#directorsInput
[(ngModel)]="peopleData.directors[i]"
[suggestions]="getDirectorsSuggestions()"
placeholder="Director name"
Expand Down Expand Up @@ -68,6 +70,7 @@ <h3>People</h3>
@for (genre of peopleData.genres; track $index; let i = $index) {
<div class="array-item">
<app-typeahead-input
#genresInput
[(ngModel)]="peopleData.genres[i]"
[suggestions]="getGenresSuggestions()"
placeholder="Genre name"
Expand Down Expand Up @@ -98,6 +101,7 @@ <h3>People</h3>
@for (producer of peopleData.producers; track $index; let i = $index) {
<div class="array-item">
<app-typeahead-input
#producersInput
[(ngModel)]="peopleData.producers[i]"
[suggestions]="getProducersSuggestions()"
placeholder="Producer name"
Expand Down Expand Up @@ -128,6 +132,7 @@ <h3>People</h3>
@for (writer of peopleData.writers; track $index; let i = $index) {
<div class="array-item">
<app-typeahead-input
#writersInput
[(ngModel)]="peopleData.writers[i]"
[suggestions]="getWritersSuggestions()"
placeholder="Writer name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export class TypeaheadInputComponent implements ControlValueAccessor, OnInit, On
}
}

focus(): void {
if (this.inputElement) {
this.inputElement.nativeElement.focus();
}
}

writeValue(value: string): void {
this.displayValue = value || '';
}
Expand Down
Loading
Loading