Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/MediaBrowser.Common/Media/MediaConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace MediaBrowser.Media;
public class MediaConfig(IConfiguration configuration)
{
public string CastDirectory { get; } = configuration["media:castDirectory"]!;
public string DeletedDirectory { get; } = configuration["media:deletedDirectory"]!;
public string DirectorsDirectory { get; } = configuration["media:directorsDirectory"]!;
public string GenresDirectory { get; } = configuration["media:genresDirectory"]!;
public string? ImportDirectory { get; } = configuration["media:importDirectory"];
Expand Down
53 changes: 53 additions & 0 deletions src/MediaBrowser.Common/Media/MediaController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,59 @@ public async Task<ActionResult<MediaReadModel>> Get(Guid id)
return media.ToReadModel(mediaConfig);
}

[HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id)
{
var media = await context.Media.Where(m => m.Id == id).FirstOrDefaultAsync();
if (media == null)
{
return NotFound();
}

var ids = new List<Guid>
{
media.Id
};

IEnumerable<string> GetFiles(MediaEntity mediaEntity)
{
yield return mediaConfig.MediaFileLocation(media, ".nfo");
if (!mediaEntity.IsImage())
{
yield return mediaConfig.MediaFileLocation(mediaEntity, ".jpg");
yield return mediaConfig.MediaFileLocation(mediaEntity, "-fanart.jpg");
}
}

var files = GetFiles(media).ToList();

if (media.ParentId == null)
{
files.Add(mediaConfig.MediaFileLocation(media));

foreach (var chapter in await context.Media.Where(m => m.ParentId == id).ToListAsync())
{
ids.Add(chapter.Id);
files.AddRange(GetFiles(chapter));
}
}

await context.MediaJoined.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync();

foreach (var file in files)
{
if (System.IO.File.Exists(file))
{
var deletedFile = Path.Combine(mediaConfig.DeletedDirectory, Path.GetFileName(file));
System.IO.File.Move(file, deletedFile);
}
}

await context.SaveChangesAsync();

return Ok();
}

[HttpPut("{id:guid}")]
public async Task<ActionResult<MediaReadModel>> Update(Guid id, [FromBody] UpdateMediaRequest request)
{
Expand Down
1 change: 1 addition & 0 deletions src/MediaBrowser.Frontend/src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<div class="app-container">
<app-toast></app-toast>
<app-navigation-tabs></app-navigation-tabs>
<main class="main-content" [class.no-sidebar]="!usersService.isAuthenticated()">
<router-outlet></router-outlet>
Expand Down
4 changes: 2 additions & 2 deletions src/MediaBrowser.Frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Routes } from '@angular/router';
import { authGuard } from './guards/auth.guard';
import { SearchComponent } from './search/search';
import { SearchQueryParams } from './search/search-query-params';

export const routes: Routes = [

Expand Down Expand Up @@ -55,6 +55,6 @@ export const routes: Routes = [
},
{
path: '**',
redirectTo: '/search?sort=' + SearchComponent.DEFAULT_SORT
redirectTo: '/search?sort=' + SearchQueryParams.DEFAULT_SORT
}
];
15 changes: 15 additions & 0 deletions src/MediaBrowser.Frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { App } from './app';
import { ToastService } from './toast/toast.service';

describe('App', () => {
beforeEach(async () => {
Expand All @@ -24,4 +25,18 @@ describe('App', () => {
expect(compiled.querySelector('app-navigation-tabs')).toBeTruthy();
expect(compiled.querySelector('main.main-content')).toBeTruthy();
});

it('should render a success toast at the app shell when one is shown', () => {
const fixture = TestBed.createComponent(App);
const toastService = TestBed.inject(ToastService);

toastService.showSuccess('Saved successfully');
fixture.detectChanges();

const compiled = fixture.nativeElement as HTMLElement;
const toast = compiled.querySelector('.toast');

expect(toast?.textContent).toContain('Saved successfully');
expect(toast?.classList.contains('toast--success')).toBe(true);
});
});
3 changes: 2 additions & 1 deletion src/MediaBrowser.Frontend/src/app/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Component, signal, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NavigationTabsComponent } from './navigation-tabs/navigation-tabs';
import { ToastComponent } from './toast/toast';
import { UsersService } from './services/users.service';

@Component({
selector: 'app-root',
imports: [RouterOutlet, NavigationTabsComponent],
imports: [RouterOutlet, NavigationTabsComponent, ToastComponent],
templateUrl: './app.html',
styleUrls: ['./app.css']
})
Expand Down
6 changes: 3 additions & 3 deletions src/MediaBrowser.Frontend/src/app/import/import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('ImportComponent', () => {
expect(component.uploadError).toBeNull();
});

it('shows an upload error when dropped file upload fails', async () => {
it('does not show an inline upload error when dropped file upload fails', async () => {
const importService = getImportServiceMock();
const uploadError = new Error('upload failed');
importService.files.mockReturnValue(of([]));
Expand All @@ -186,12 +186,12 @@ describe('ImportComponent', () => {
await fixture.whenStable();

expect(importService.uploadFile).toHaveBeenCalledWith(droppedFile);
expect(component.uploadError).toBe('Failed to upload file. Please try again.');
expect(component.uploadError).toBeNull();
expect(consoleErrorSpy).toHaveBeenCalledWith('Error uploading file:', uploadError);

fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.upload-error')?.textContent).toContain('Failed to upload file. Please try again.');
expect(compiled.querySelector('.upload-error')).toBeNull();

consoleErrorSpy.mockRestore();
});
Expand Down
1 change: 0 additions & 1 deletion src/MediaBrowser.Frontend/src/app/import/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ export class ImportComponent implements OnInit {
await this.scanDirectory();
} catch (error) {
console.error('Error uploading file:', error);
this.uploadError = 'Failed to upload file. Please try again.';
this.cdr.detectChanges();
} finally {
this.isUploading = false;
Expand Down
10 changes: 7 additions & 3 deletions src/MediaBrowser.Frontend/src/app/login/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SearchComponent } from '../search/search';
import { SearchQueryParams } from '../search/search-query-params';
import { UsersService } from '../services/users.service';
import { LoginComponent } from './login';

Expand Down Expand Up @@ -101,7 +102,7 @@ describe('LoginComponent', () => {
expect(clearPagePositionSpy).toHaveBeenCalledTimes(1);
expect(mocks.router.navigate).toHaveBeenCalledWith(['/search'], {
queryParams: {
sort: SearchComponent.DEFAULT_SORT
sort: SearchQueryParams.DEFAULT_SORT
},
queryParamsHandling: 'replace'
});
Expand All @@ -110,9 +111,10 @@ describe('LoginComponent', () => {
expect(detectChanges).toHaveBeenCalledTimes(1);
});

it('shows a generic error message when login fails', async () => {
it('does not show an inline API error when login fails', async () => {
const { component, mocks } = await createComponent();
const detectChanges = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

(component as any).cdr = { detectChanges };

Expand All @@ -127,9 +129,11 @@ describe('LoginComponent', () => {
password: 'wrong-password'
});
expect(mocks.router.navigate).not.toHaveBeenCalled();
expect(component.errorMessage).toBe('An error occurred during login. Please try again.');
expect(component.errorMessage).toBe('');
expect(component.isLoading).toBe(false);
expect(detectChanges).toHaveBeenCalledTimes(1);

consoleErrorSpy.mockRestore();
});

it('submits only on Enter for username keypress', async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/MediaBrowser.Frontend/src/app/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Router } from '@angular/router';
import { UsersService } from '../services/users.service';
import { firstValueFrom } from 'rxjs';
import { SearchComponent } from '../search/search';
import { SearchQueryParams } from '../search/search-query-params';

@Component({
selector: 'app-login',
Expand Down Expand Up @@ -39,13 +40,12 @@ export class LoginComponent {
SearchComponent.clearPagePositionState();
this.router.navigate(['/search'], {
queryParams: {
sort: SearchComponent.DEFAULT_SORT
sort: SearchQueryParams.DEFAULT_SORT
},
queryParamsHandling: 'replace'
});
} catch (error) {
console.error('Login error:', error);
this.errorMessage = 'An error occurred during login. Please try again.';
} finally {
this.isLoading = false;
this.cdr.detectChanges();
Expand Down
122 changes: 122 additions & 0 deletions src/MediaBrowser.Frontend/src/app/media-editor/media-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
/* Button base styles */
.back-button,
.cancel-button,
.delete-button,
.save-button,
.remove-button,
.add-button,
Expand Down Expand Up @@ -56,6 +57,7 @@
}

.cancel-button,
.delete-button,
.save-button {
padding: 0.5rem 1rem;
border-radius: var(--radius2);
Expand All @@ -71,6 +73,15 @@
opacity: 0.8;
}

.delete-button {
background: var(--error-bg);
color: var(--error-fg);
}

.delete-button:hover:not(:disabled) {
opacity: 0.85;
}

.save-button {
background: var(--primary3-bg);
color: var(--fg4);
Expand All @@ -81,12 +92,108 @@
}

.cancel-button:disabled,
.delete-button:disabled,
.save-button:disabled,
.thumbnail-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.55);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(2px);
}

.modal-content {
background-color: var(--surface-bg);
border: 1px solid var(--surface2-bg);
border-radius: var(--radius1);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
width: min(92vw, 460px);
}

.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--surface1-bg);
}

.modal-header h3 {
margin: 0;
color: var(--fg1);
}

.close-button {
background: none;
border: none;
color: var(--fg2);
cursor: pointer;
width: 2rem;
height: 2rem;
border-radius: var(--radius1);
}

.close-button:hover:not(:disabled) {
background: var(--surface1-bg);
}

.modal-body {
padding: 1.5rem;
}

.modal-text {
margin: 0 0 1rem;
color: var(--fg2);
}

.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}

.modal-button {
border: none;
border-radius: var(--radius1);
padding: 0.75rem 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}

.modal-button:disabled,
.close-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

.cancel-modal-button {
background: var(--surface2-bg);
color: var(--fg2);
}

.confirm-delete-button {
background: var(--error-bg);
color: var(--error-fg);
}

.confirm-delete-button:hover:not(:disabled) {
opacity: 0.85;
}

.editor-content {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -149,4 +256,19 @@
height: 100%;
width: calc(100% - 2rem);
}
}

@media (max-width: 480px) {
.header-actions,
.modal-footer {
flex-direction: column;
}

.delete-button,
.cancel-button,
.save-button,
.modal-button {
justify-content: center;
width: 100%;
}
}
Loading
Loading