feat(opds): allow user to set sorting for opds feed in settings (#1824)

* feat(opds): allow user to set sorting for opds feed in settings

* patch(opds): re-add search normalization

* patch(opds): add series to feedid determination

---------

Co-authored-by: WorldTeacher <admin@theprivateserver.de>
This commit is contained in:
WorldTeacher
2025-12-16 03:24:01 +01:00
committed by GitHub
parent 83f5e3a31d
commit a4a94b731a
16 changed files with 427 additions and 7 deletions

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest;
import com.adityachandel.booklore.service.opds.OpdsUserV2Service;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -46,4 +47,13 @@ public class OpdsUserV2Controller {
@Parameter(description = "ID of the OPDS user to delete") @PathVariable Long id) {
service.deleteOpdsUser(id);
}
}
@Operation(summary = "Update OPDS user", description = "Update an OPDS user's settings by ID.")
@ApiResponse(responseCode = "200", description = "OPDS user updated successfully")
@PatchMapping("/{id}")
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
public OpdsUserV2 updateUser(
@Parameter(description = "ID of the OPDS user to update") @PathVariable Long id,
@Parameter(description = "OPDS user update request") @RequestBody OpdsUserV2UpdateRequest updateRequest) {
return service.updateOpdsUser(id, updateRequest);
}}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.dto;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.*;
@@ -14,4 +15,5 @@ public class OpdsUserV2 {
private String username;
@JsonIgnore
private String passwordHash;
private OpdsSortOrder sortOrder;
}

View File

@@ -1,9 +1,11 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import lombok.Data;
@Data
public class OpdsUserV2CreateRequest {
private String username;
private String password;
private OpdsSortOrder sortOrder;
}

View File

@@ -0,0 +1,10 @@
package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import jakarta.validation.constraints.NotNull;
public record OpdsUserV2UpdateRequest(
@NotNull(message = "Sort order is required")
OpdsSortOrder sortOrder
) {
}

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.model.entity;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import jakarta.persistence.*;
import lombok.*;
@@ -28,6 +29,11 @@ public class OpdsUserV2Entity {
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "sort_order", length = 20)
@Builder.Default
private OpdsSortOrder sortOrder = OpdsSortOrder.RECENT;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

View File

@@ -0,0 +1,13 @@
package com.adityachandel.booklore.model.enums;
public enum OpdsSortOrder {
RECENT,
TITLE_ASC,
TITLE_DESC,
AUTHOR_ASC,
AUTHOR_DESC,
SERIES_ASC,
SERIES_DESC,
RATING_ASC,
RATING_DESC
}

View File

@@ -7,11 +7,12 @@ import com.adityachandel.booklore.model.dto.*;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.adityachandel.booklore.repository.BookOpdsRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
import com.adityachandel.booklore.repository.UserRepository;
import com.adityachandel.booklore.service.library.LibraryService;
import com.adityachandel.booklore.util.BookUtils;
import com.adityachandel.booklore.service.library.LibraryService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@@ -400,4 +401,170 @@ public class OpdsBookService {
}
return dto;
}
public Page<Book> applySortOrder(Page<Book> booksPage, OpdsSortOrder sortOrder) {
if (sortOrder == null || sortOrder == OpdsSortOrder.RECENT) {
return booksPage; // Already sorted by addedOn DESC from repository
}
List<Book> sortedBooks = new ArrayList<>(booksPage.getContent());
switch (sortOrder) {
case TITLE_ASC -> sortedBooks.sort((b1, b2) -> {
String title1 = b1.getMetadata() != null && b1.getMetadata().getTitle() != null
? b1.getMetadata().getTitle() : "";
String title2 = b2.getMetadata() != null && b2.getMetadata().getTitle() != null
? b2.getMetadata().getTitle() : "";
return title1.compareToIgnoreCase(title2);
});
case TITLE_DESC -> sortedBooks.sort((b1, b2) -> {
String title1 = b1.getMetadata() != null && b1.getMetadata().getTitle() != null
? b1.getMetadata().getTitle() : "";
String title2 = b2.getMetadata() != null && b2.getMetadata().getTitle() != null
? b2.getMetadata().getTitle() : "";
return title2.compareToIgnoreCase(title1);
});
case AUTHOR_ASC -> sortedBooks.sort((b1, b2) -> {
String author1 = getFirstAuthor(b1);
String author2 = getFirstAuthor(b2);
return author1.compareToIgnoreCase(author2);
});
case AUTHOR_DESC -> sortedBooks.sort((b1, b2) -> {
String author1 = getFirstAuthor(b1);
String author2 = getFirstAuthor(b2);
return author2.compareToIgnoreCase(author1);
});
case SERIES_ASC -> sortedBooks.sort((b1, b2) -> {
String series1 = getSeriesName(b1);
String series2 = getSeriesName(b2);
boolean hasSeries1 = !series1.isEmpty();
boolean hasSeries2 = !series2.isEmpty();
// Books without series come after books with series
if (!hasSeries1 && !hasSeries2) {
// Both have no series, sort by addedOn descending
return compareByAddedOn(b2, b1);
}
if (!hasSeries1) return 1;
if (!hasSeries2) return -1;
// Both have series, sort by series name then number
int seriesComp = series1.compareToIgnoreCase(series2);
if (seriesComp != 0) return seriesComp;
return Float.compare(getSeriesNumber(b1), getSeriesNumber(b2));
});
case SERIES_DESC -> sortedBooks.sort((b1, b2) -> {
String series1 = getSeriesName(b1);
String series2 = getSeriesName(b2);
boolean hasSeries1 = !series1.isEmpty();
boolean hasSeries2 = !series2.isEmpty();
// Books without series come after books with series
if (!hasSeries1 && !hasSeries2) {
// Both have no series, sort by addedOn descending
return compareByAddedOn(b2, b1);
}
if (!hasSeries1) return 1;
if (!hasSeries2) return -1;
// Both have series, sort by series name then number
int seriesComp = series2.compareToIgnoreCase(series1);
if (seriesComp != 0) return seriesComp;
return Float.compare(getSeriesNumber(b2), getSeriesNumber(b1));
});
case RATING_ASC -> sortedBooks.sort((b1, b2) -> {
Float rating1 = calculateRating(b1);
Float rating2 = calculateRating(b2);
// Books with no rating go to the end
if (rating1 == null && rating2 == null) {
// Both have no rating, fall back to addedOn descending
return compareByAddedOn(b2, b1);
}
if (rating1 == null) return 1;
if (rating2 == null) return -1;
int ratingComp = Float.compare(rating1, rating2); // Ascending order (lowest first)
if (ratingComp != 0) return ratingComp;
// Same rating, fall back to addedOn descending
return compareByAddedOn(b2, b1);
});
case RATING_DESC -> sortedBooks.sort((b1, b2) -> {
Float rating1 = calculateRating(b1);
Float rating2 = calculateRating(b2);
// Books with no rating go to the end
if (rating1 == null && rating2 == null) {
// Both have no rating, fall back to addedOn descending
return compareByAddedOn(b2, b1);
}
if (rating1 == null) return 1;
if (rating2 == null) return -1;
int ratingComp = Float.compare(rating2, rating1); // Descending order (highest first)
if (ratingComp != 0) return ratingComp;
// Same rating, fall back to addedOn descending
return compareByAddedOn(b2, b1);
});
}
return new PageImpl<>(sortedBooks, booksPage.getPageable(), booksPage.getTotalElements());
}
private String getFirstAuthor(Book book) {
if (book.getMetadata() != null && book.getMetadata().getAuthors() != null
&& !book.getMetadata().getAuthors().isEmpty()) {
return book.getMetadata().getAuthors().iterator().next();
}
return "";
}
private String getSeriesName(Book book) {
if (book.getMetadata() != null && book.getMetadata().getSeriesName() != null) {
return book.getMetadata().getSeriesName();
}
return "";
}
private Float getSeriesNumber(Book book) {
if (book.getMetadata() != null && book.getMetadata().getSeriesNumber() != null) {
return book.getMetadata().getSeriesNumber();
}
return Float.MAX_VALUE;
}
private int compareByAddedOn(Book b1, Book b2) {
if (b1.getAddedOn() == null && b2.getAddedOn() == null) return 0;
if (b1.getAddedOn() == null) return 1;
if (b2.getAddedOn() == null) return -1;
return b1.getAddedOn().compareTo(b2.getAddedOn());
}
private Float calculateRating(Book book) {
if (book.getMetadata() == null) {
return null;
}
Double hardcoverRating = book.getMetadata().getHardcoverRating();
Double amazonRating = book.getMetadata().getAmazonRating();
Double goodreadsRating = book.getMetadata().getGoodreadsRating();
double sum = 0;
int count = 0;
if (hardcoverRating != null && hardcoverRating > 0) {
sum += hardcoverRating;
count++;
}
if (amazonRating != null && amazonRating > 0) {
sum += amazonRating;
count++;
}
if (goodreadsRating != null && goodreadsRating > 0) {
sum += goodreadsRating;
count++;
}
if (count == 0) {
return null;
}
return (float) (sum / count);
}
}

View File

@@ -5,6 +5,7 @@ import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.Library;
import com.adityachandel.booklore.model.dto.MagicShelf;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.adityachandel.booklore.service.MagicShelfService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -328,6 +329,7 @@ public class OpdsFeedService {
int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE);
Long userId = getUserId();
OpdsSortOrder sortOrder = getSortOrder();
Page<Book> booksPage;
if (magicShelfId != null) {
@@ -340,6 +342,9 @@ public class OpdsFeedService {
booksPage = opdsBookService.getBooksPage(userId, query, libraryId, shelfId, page - 1, size);
}
// Apply user's preferred sort order
booksPage = opdsBookService.applySortOrder(booksPage, sortOrder);
String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author, series);
String feedId = determineFeedId(libraryId, shelfId, magicShelfId, author, series);
@@ -375,11 +380,15 @@ public class OpdsFeedService {
public String generateRecentFeed(HttpServletRequest request) {
Long userId = getUserId();
OpdsSortOrder sortOrder = getSortOrder();
int page = Math.max(1, parseLongParam(request, "page", 1L).intValue());
int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE);
Page<Book> booksPage = opdsBookService.getRecentBooksPage(userId, page - 1, size);
// Apply user's preferred sort order
booksPage = opdsBookService.applySortOrder(booksPage, sortOrder);
var feed = new StringBuilder("""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/terms/" xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
@@ -630,4 +639,11 @@ public class OpdsFeedService {
? details.getOpdsUserV2().getUserId()
: null;
}
private OpdsSortOrder getSortOrder() {
OpdsUserDetails details = authenticationService.getOpdsUser();
return details != null && details.getOpdsUserV2() != null && details.getOpdsUserV2().getSortOrder() != null
? details.getOpdsUserV2().getSortOrder()
: OpdsSortOrder.RECENT;
}
}

View File

@@ -5,6 +5,7 @@ import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest;
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
@@ -45,6 +46,7 @@ public class OpdsUserV2Service {
.user(userEntity)
.username(request.getUsername())
.passwordHash(passwordEncoder.encode(request.getPassword()))
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : com.adityachandel.booklore.model.enums.OpdsSortOrder.RECENT)
.build();
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
@@ -64,4 +66,17 @@ public class OpdsUserV2Service {
}
opdsUserV2Repository.delete(user);
}
public OpdsUserV2 updateOpdsUser(Long userId, OpdsUserV2UpdateRequest request) {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
OpdsUserV2Entity user = opdsUserV2Repository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found with ID: " + userId));
if (!user.getUser().getId().equals(bookLoreUser.getId())) {
throw new AccessDeniedException("You are not allowed to update this user");
}
user.setSortOrder(request.sortOrder());
return mapper.toDto(opdsUserV2Repository.save(user));
}
}

View File

@@ -0,0 +1,2 @@
-- Add sort_order column to opds_user_v2 table
ALTER TABLE opds_user_v2 ADD COLUMN sort_order VARCHAR(20) NOT NULL DEFAULT 'RECENT';

View File

@@ -0,0 +1,13 @@
package com.adityachandel.booklore.model.enums;
public enum OpdsSortOrder {
RECENT,
TITLE_ASC,
TITLE_DESC,
AUTHOR_ASC,
AUTHOR_DESC,
SERIES_ASC,
SERIES_DESC,
RATING_ASC,
RATING_DESC
}

View File

@@ -8,6 +8,7 @@ import com.adityachandel.booklore.model.dto.Library;
import com.adityachandel.booklore.model.dto.OpdsUserV2;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
import com.adityachandel.booklore.service.MagicShelfService;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
@@ -51,6 +52,7 @@ class OpdsFeedServiceTest {
OpdsUserV2 v2 = mock(OpdsUserV2.class);
when(userDetails.getOpdsUserV2()).thenReturn(v2);
when(v2.getUserId()).thenReturn(TEST_USER_ID);
when(v2.getSortOrder()).thenReturn(OpdsSortOrder.RECENT);
when(authenticationService.getOpdsUser()).thenReturn(userDetails);
return userDetails;
}
@@ -152,6 +154,7 @@ class OpdsFeedServiceTest {
Page<Book> page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1);
when(opdsBookService.getBooksPage(eq(TEST_USER_ID), any(), any(), any(), eq(0), eq(50))).thenReturn(page);
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
String xml = opdsFeedService.generateCatalogFeed(request);
assertThat(xml).contains("Book Title");
@@ -173,6 +176,7 @@ class OpdsFeedServiceTest {
Page<Book> page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0);
when(opdsBookService.getBooksPage(any(), any(), any(), any(), anyInt(), anyInt())).thenReturn(page);
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
String xml = opdsFeedService.generateCatalogFeed(request);
assertThat(xml).contains("</feed>");
@@ -196,6 +200,7 @@ class OpdsFeedServiceTest {
Page<Book> page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1);
when(opdsBookService.getRecentBooksPage(eq(TEST_USER_ID), eq(0), eq(50))).thenReturn(page);
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
String xml = opdsFeedService.generateRecentFeed(request);
assertThat(xml).contains("Recent Book");
@@ -214,6 +219,7 @@ class OpdsFeedServiceTest {
Page<Book> page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0);
when(opdsBookService.getRecentBooksPage(any(), anyInt(), anyInt())).thenReturn(page);
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
String xml = opdsFeedService.generateRecentFeed(request);
assertThat(xml).contains("</feed>");

View File

@@ -113,6 +113,12 @@
<span>Username</span>
</div>
</th>
<th>
<div class="header-content">
<i class="pi pi-sort-alt"></i>
<span>Sort Order</span>
</div>
</th>
<th>
<div class="header-content">
<i class="pi pi-key"></i>
@@ -137,6 +143,47 @@
<span class="username">{{ user.username }}</span>
</div>
</td>
<td>
@if (editingUserId === user.id) {
<div class="flex items-center gap-2">
<p-select
[options]="sortOrderOptions"
[(ngModel)]="editingSortOrder"
optionLabel="label"
optionValue="value"
[style]="{width: '180px'}"
size="small"
[appendTo]="'body'">
</p-select>
<p-button
icon="pi pi-check"
severity="success"
size="small"
[text]="true"
(onClick)="saveSortOrder(user)">
</p-button>
<p-button
icon="pi pi-times"
severity="secondary"
size="small"
[text]="true"
(onClick)="cancelEdit()">
</p-button>
</div>
} @else {
<div class="flex items-center gap-2">
<span class="sort-order-badge">{{ getSortOrderLabel(user.sortOrder) }}</span>
<p-button
icon="pi pi-pencil"
severity="info"
size="small"
[text]="true"
(onClick)="startEdit(user)"
pTooltip="Edit sort order">
</p-button>
</div>
}
</td>
<td>
<div class="flex items-center gap-2">
<p-password
@@ -171,7 +218,7 @@
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="3">
<td colspan="4">
<div class="empty-message">
<i class="pi pi-users"></i>
<p class="empty-title">No users found</p>
@@ -211,6 +258,23 @@
[(ngModel)]="newUser.password"
placeholder="Enter password"/>
</div>
<div class="form-field">
<label for="sortOrder">Default Sort Order</label>
<p-select
id="sortOrder"
[options]="sortOrderOptions"
[(ngModel)]="newUser.sortOrder"
optionLabel="label"
optionValue="value"
[style]="{width: '100%'}"
placeholder="Select sort order"
[appendTo]="'body'">
</p-select>
<small class="form-hint">
<i class="pi pi-info-circle"></i>
This will determine how books appear in the OPDS feed for this user
</small>
</div>
</div>
<ng-template pTemplate="footer">
<div class="dialog-actions">

View File

@@ -264,6 +264,17 @@
font-weight: 500;
}
.sort-order-badge {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-radius: 1rem;
background: var(--p-primary-50);
color: var(--p-primary-700);
font-size: 0.8125rem;
font-weight: 500;
}
.actions-cell {
text-align: center;
}
@@ -332,6 +343,22 @@
color: var(--p-text-muted-color);
}
}
.form-hint {
display: flex;
align-items: flex-start;
gap: 0.375rem;
color: var(--p-text-muted-color);
font-size: 0.75rem;
line-height: 1.4;
margin-top: 0.25rem;
.pi {
font-size: 0.625rem;
margin-top: 0.125rem;
flex-shrink: 0;
}
}
}
.dialog-actions {

View File

@@ -9,7 +9,7 @@ import {Dialog} from 'primeng/dialog';
import {FormsModule} from '@angular/forms';
import {ConfirmDialog} from 'primeng/confirmdialog';
import {ConfirmationService, MessageService} from 'primeng/api';
import {OpdsService, OpdsUserV2, OpdsUserV2CreateRequest} from './opds.service';
import {OpdsService, OpdsSortOrder, OpdsUserV2, OpdsUserV2CreateRequest} from './opds.service';
import {catchError, filter, take, takeUntil, tap} from 'rxjs/operators';
import {UserService} from '../user-management/user.service';
import {of, Subject} from 'rxjs';
@@ -18,6 +18,7 @@ import {ToggleSwitch} from 'primeng/toggleswitch';
import {AppSettingsService} from '../../../shared/service/app-settings.service';
import {AppSettingKey} from '../../../shared/model/app-settings.model';
import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component';
import {Select} from 'primeng/select';
@Component({
selector: 'app-opds-settings',
@@ -32,7 +33,8 @@ import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-
TableModule,
Password,
ToggleSwitch,
ExternalDocLinkComponent
ExternalDocLinkComponent,
Select
],
providers: [ConfirmationService],
templateUrl: './opds-settings.html',
@@ -52,13 +54,28 @@ export class OpdsSettings implements OnInit, OnDestroy {
users: OpdsUserV2[] = [];
loading = false;
showCreateUserDialog = false;
newUser: OpdsUserV2CreateRequest = {username: '', password: ''};
newUser: OpdsUserV2CreateRequest = {username: '', password: '', sortOrder: 'RECENT'};
passwordVisibility: boolean[] = [];
hasPermission = false;
editingUserId: number | null = null;
editingSortOrder: OpdsSortOrder | null = null;
private readonly destroy$ = new Subject<void>();
dummyPassword: string = "***********************";
sortOrderOptions = [
{ label: 'Recently Added', value: 'RECENT' as OpdsSortOrder },
{ label: 'Title (A-Z)', value: 'TITLE_ASC' as OpdsSortOrder },
{ label: 'Title (Z-A)', value: 'TITLE_DESC' as OpdsSortOrder },
{ label: 'Author (A-Z)', value: 'AUTHOR_ASC' as OpdsSortOrder },
{ label: 'Author (Z-A)', value: 'AUTHOR_DESC' as OpdsSortOrder },
{ label: 'Series (A-Z)', value: 'SERIES_ASC' as OpdsSortOrder },
{ label: 'Series (Z-A)', value: 'SERIES_DESC' as OpdsSortOrder },
{ label: 'Rating (Low to High)', value: 'RATING_ASC' as OpdsSortOrder },
{ label: 'Rating (High to Low)', value: 'RATING_DESC' as OpdsSortOrder }
];
ngOnInit(): void {
this.loading = true;
@@ -189,13 +206,51 @@ export class OpdsSettings implements OnInit, OnDestroy {
private resetCreateUserDialog(): void {
this.showCreateUserDialog = false;
this.newUser = {username: '', password: ''};
this.newUser = {username: '', password: '', sortOrder: 'RECENT'};
}
private showMessage(severity: string, summary: string, detail: string): void {
this.messageService.add({severity, summary, detail});
}
getSortOrderLabel(sortOrder?: OpdsSortOrder): string {
if (!sortOrder) return 'Recently Added';
const option = this.sortOrderOptions.find(o => o.value === sortOrder);
return option ? option.label : 'Recently Added';
}
startEdit(user: OpdsUserV2): void {
this.editingUserId = user.id;
this.editingSortOrder = user.sortOrder || 'RECENT';
}
cancelEdit(): void {
this.editingUserId = null;
this.editingSortOrder = null;
}
saveSortOrder(user: OpdsUserV2): void {
if (!this.editingSortOrder || !user.id) return;
this.opdsService.updateUser(user.id, this.editingSortOrder).pipe(
takeUntil(this.destroy$),
catchError(err => {
console.error('Error updating sort order:', err);
this.showMessage('error', 'Error', 'Failed to update sort order');
return of(null);
})
).subscribe(updatedUser => {
if (updatedUser) {
const index = this.users.findIndex(u => u.id === user.id);
if (index !== -1) {
this.users[index] = updatedUser;
}
this.showMessage('success', 'Success', 'Sort order updated successfully');
}
this.cancelEdit();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -3,15 +3,23 @@ import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {API_CONFIG} from '../../../core/config/api-config';
export type OpdsSortOrder = 'RECENT' | 'TITLE_ASC' | 'TITLE_DESC' | 'AUTHOR_ASC' | 'AUTHOR_DESC' | 'SERIES_ASC' | 'SERIES_DESC' | 'RATING_ASC' | 'RATING_DESC';
export interface OpdsUserV2CreateRequest {
username: string;
password: string;
sortOrder?: OpdsSortOrder;
}
export interface OpdsUserV2UpdateRequest {
sortOrder: OpdsSortOrder;
}
export interface OpdsUserV2 {
id: number;
userId: number;
username: string;
sortOrder?: OpdsSortOrder;
}
@Injectable({
@@ -30,6 +38,10 @@ export class OpdsService {
return this.http.post<OpdsUserV2>(this.baseUrl, user);
}
updateUser(id: number, sortOrder: OpdsSortOrder): Observable<OpdsUserV2> {
return this.http.patch<OpdsUserV2>(`${this.baseUrl}/${id}`, { sortOrder });
}
deleteCredential(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}