From 169d819de50aa4c3c1c6d35e4e604c26aa6eee3d Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:05:08 +0200 Subject: [PATCH] Allow deleting Scrobble events --- API/Controllers/ScrobblingController.cs | 16 +++- API/DTOs/Scrobbling/ScrobbleEventDto.cs | 1 + .../Repositories/ScrobbleEventRepository.cs | 16 +++- .../app/_models/scrobbling/scrobble-event.ts | 1 + .../src/app/_services/scrobbling.service.ts | 6 +- .../user-scrobble-history.component.html | 21 ++++- .../user-scrobble-history.component.ts | 84 +++++++++++++++++-- .../manage-scrobble-errors.component.html | 2 +- .../app/typeahead/_models/selection-model.ts | 22 +++++ UI/Web/src/assets/langs/en.json | 2 + 10 files changed, 160 insertions(+), 11 deletions(-) diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 3904cb8e0..986f4f8e7 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -254,7 +254,7 @@ public class ScrobblingController : BaseApiController } /// - /// Adds a hold against the Series for user's scrobbling + /// Remove a hold against the Series for user's scrobbling /// /// /// @@ -281,4 +281,18 @@ public class ScrobblingController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); return Ok(user is {HasRunScrobbleEventGeneration: true}); } + + /// + /// Delete the given scrobble events if they belong to that user + /// + /// + /// + [HttpPost("bulk-remove-events")] + public async Task BulkRemoveScrobbleEvents(IList eventIds) + { + var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), eventIds); + _unitOfWork.ScrobbleRepository.Remove(events); + await _unitOfWork.CommitAsync(); + return Ok(); + } } diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index 7b1ccd75a..562d923ff 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -5,6 +5,7 @@ namespace API.DTOs.Scrobbling; public sealed record ScrobbleEventDto { + public long Id { get; init; } public string SeriesName { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 26496e8f4..144a3b88e 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -39,6 +39,13 @@ public interface IScrobbleRepository /// Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType, bool isNotProcessed = false); Task> GetUserEventsForSeries(int userId, int seriesId); + /// + /// Return the events with given ids, when belonging to the passed user + /// + /// + /// + /// + Task> GetUserEvents(int userId, IList scrobbleEventIds); Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination); Task> GetAllEventsForSeries(int seriesId); Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds); @@ -166,13 +173,20 @@ public class ScrobbleRepository : IScrobbleRepository public async Task> GetUserEventsForSeries(int userId, int seriesId) { return await _context.ScrobbleEvent - .Where(e => e.AppUserId == userId && !e.IsProcessed) + .Where(e => e.AppUserId == userId && !e.IsProcessed && e.SeriesId == seriesId) .Include(e => e.Series) .OrderBy(e => e.LastModifiedUtc) .AsSplitQuery() .ToListAsync(); } + public async Task> GetUserEvents(int userId, IList scrobbleEventIds) + { + return await _context.ScrobbleEvent + .Where(e => e.AppUserId == userId && scrobbleEventIds.Contains(e.Id)) + .ToListAsync(); + } + public async Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination) { var query = _context.ScrobbleEvent diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts index 48a75afda..7db1ceeaa 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event.ts @@ -7,6 +7,7 @@ export enum ScrobbleEventType { } export interface ScrobbleEvent { + id: number; seriesName: string; seriesId: number; libraryId: number; diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index 76b9212f4..cfc7b34ac 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -104,6 +104,10 @@ export class ScrobblingService { triggerScrobbleEventGeneration() { return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse); - } + + bulkRemoveEvents(eventIds: number[]) { + return this.httpClient.post(this.baseUrl + "scrobbling/bulk-remove-events", eventIds) + } + } diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 51caae2f3..cdf31441a 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -20,7 +20,10 @@
- +
+ + +
@@ -40,6 +43,20 @@ [sorts]="[{prop: 'createdUtc', dir: 'desc'}]" > + + +
+ + +
+
+ + + +
+ {{t('created-header')}} @@ -101,7 +118,7 @@ - + {{t('is-processed-header')}} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index c0306c4cf..ac48b6add 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -1,4 +1,12 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + HostListener, + inject, + OnInit +} from '@angular/core'; import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -9,7 +17,7 @@ import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-fi import {debounceTime, take} from "rxjs/operators"; import {PaginatedResult} from "../../_models/pagination"; import {SortEvent} from "../table/_directives/sortable-header.directive"; -import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; import {translate, TranslocoModule} from "@jsverse/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; @@ -19,6 +27,7 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {AsyncPipe} from "@angular/common"; import {AccountService} from "../../_services/account.service"; import {ToastrService} from "ngx-toastr"; +import {SelectionModel} from "../../typeahead/_models/selection-model"; export interface DataTablePage { pageNumber: number, @@ -30,7 +39,7 @@ export interface DataTablePage { @Component({ selector: 'app-user-scrobble-history', imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, - DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe], + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -48,8 +57,6 @@ export class UserScrobbleHistoryComponent implements OnInit { private readonly toastr = inject(ToastrService); protected readonly accountService = inject(AccountService); - - tokenExpired = false; formGroup: FormGroup = new FormGroup({ 'filter': new FormControl('', []) @@ -68,6 +75,21 @@ export class UserScrobbleHistoryComponent implements OnInit { }; hasRunScrobbleGen: boolean = false; + selections: SelectionModel = new SelectionModel(); + selectAll: boolean = false; + isShiftDown: boolean = false; + lastSelectedIndex: number | null = null; + + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(_: KeyboardEvent) { + this.isShiftDown = true; + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(_: KeyboardEvent) { + this.isShiftDown = false; + } + ngOnInit() { this.pageInfo.pageNumber = 0; @@ -118,6 +140,7 @@ export class UserScrobbleHistoryComponent implements OnInit { .pipe(take(1)) .subscribe((result: PaginatedResult) => { this.events = result.result; + this.selections = new SelectionModel(false, this.events); this.pageInfo.totalPages = result.pagination.totalPages - 1; // ngx-datatable is 0 based, Kavita is 1 based this.pageInfo.size = result.pagination.itemsPerPage; @@ -143,4 +166,55 @@ export class UserScrobbleHistoryComponent implements OnInit { this.toastr.info(translate('toasts.scrobble-gen-init')) }); } + + bulkDelete() { + if (!this.selections.hasAnySelected()) { + return; + } + + const eventIds = this.selections.selected().map(e => e.id); + + this.scrobblingService.bulkRemoveEvents(eventIds).subscribe({ + next: () => { + this.events = this.events.filter(e => !eventIds.includes(e.id)); + this.selectAll = false; + this.selections.clearSelected(); + this.pageInfo.totalElements -= eventIds.length; + this.cdRef.markForCheck(); + }, + error: err => { + console.error(err); + } + }); + } + + toggleAll() { + this.selectAll = !this.selectAll; + this.events.forEach(e => this.selections.toggle(e, this.selectAll)); + this.cdRef.markForCheck(); + } + + handleSelection(item: ScrobbleEvent, index: number) { + if (this.isShiftDown && this.lastSelectedIndex !== null) { + // Bulk select items between the last selected item and the current one + const start = Math.min(this.lastSelectedIndex, index); + const end = Math.max(this.lastSelectedIndex, index); + + for (let i = start; i <= end; i++) { + const event = this.events[i]; + if (!this.selections.isSelected(event, (e1, e2) => e1.id == e2.id)) { + this.selections.toggle(event, true); + } + } + } else { + this.selections.toggle(item); + } + + this.lastSelectedIndex = index; + + + const numberOfSelected = this.selections.selected().length; + this.selectAll = numberOfSelected === this.events.length; + this.cdRef.markForCheck(); + } } diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html index 78724272c..59a45873e 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html @@ -8,7 +8,7 @@
- +
diff --git a/UI/Web/src/app/typeahead/_models/selection-model.ts b/UI/Web/src/app/typeahead/_models/selection-model.ts index c4b2ab18a..8493a4eed 100644 --- a/UI/Web/src/app/typeahead/_models/selection-model.ts +++ b/UI/Web/src/app/typeahead/_models/selection-model.ts @@ -70,6 +70,28 @@ export class SelectionModel { return (selectedCount !== this._data.length && selectedCount !== 0) } + /** + * @return If at least one item is selected + */ + hasAnySelected(): boolean { + for (const d of this._data) { + if (d.selected) { + return true; + } + } + return false; + } + + /** + * Marks every data entry has not selected + */ + clearSelected() { + this._data = this._data.map(d => { + d.selected = false; + return d; + }); + } + /** * * @returns All Selected items diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index b9ab24ae5..44aae59a1 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -42,6 +42,8 @@ "series-header": "Series", "data-header": "Data", "is-processed-header": "Is Processed", + "select-header": "Select all", + "delete-selected": "Delete selected", "no-data": "{{common.no-data}}", "volume-and-chapter-num": "Volume {{v}} Chapter {{n}}", "volume-num": "Volume {{num}}",