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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to Library Manager will be documented in this file.

## [0.9.0-beta.122] - 2026-02-11

### Added

- **Issue #111: Sortable columns** - Library table columns (Author, Title, Status) can now be
sorted by clicking column headers. Click once for ascending, again for descending, third click
clears sort back to default order. Visual arrow indicators show active sort column and direction.
Sort state preserved during pagination and filter changes. Backend validates sort columns against
a whitelist to prevent SQL injection.

---

## [0.9.0-beta.121] - 2026-02-10

### Fixed
Expand Down
65 changes: 52 additions & 13 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- Multi-provider AI (Gemini, OpenRouter, Ollama)
"""

APP_VERSION = "0.9.0-beta.121"
APP_VERSION = "0.9.0-beta.122"
GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo

# Versioning Guide:
Expand Down Expand Up @@ -698,7 +698,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
reports = json.load(f)
except:

Check failure on line 701 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

app.py:701:13: E722 Do not use bare `except`
reports = []

# Add new report (keep last 100 reports to avoid file bloat)
Expand All @@ -722,7 +722,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
return json.load(f)
except:

Check failure on line 725 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

app.py:725:9: E722 Do not use bare `except`
return []
return []

Expand Down Expand Up @@ -1677,7 +1677,7 @@
continue
result = call_gemini(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with gemini")

Check failure on line 1680 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1680:33: F541 f-string without any placeholders
return result

elif provider == 'openrouter':
Expand All @@ -1686,13 +1686,13 @@
continue
result = call_openrouter(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with openrouter")

Check failure on line 1689 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1689:33: F541 f-string without any placeholders
return result

elif provider == 'ollama':
result = call_ollama(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with ollama")

Check failure on line 1695 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1695:33: F541 f-string without any placeholders
return result

else:
Expand Down Expand Up @@ -1794,7 +1794,7 @@
return result
elif result and result.get('transcript'):
# Got transcript but no match - still useful, return for potential AI fallback
logger.info(f"[AUDIO CHAIN] BookDB returned transcript only")

Check failure on line 1797 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1797:37: F541 f-string without any placeholders
return result
elif result is None and attempt < max_retries - 1:
# Connection might be down, wait and retry
Expand Down Expand Up @@ -2126,11 +2126,11 @@
device = "cuda"
# int8 works on all CUDA devices including GTX 1080 (compute 6.1)
# float16 only works on newer GPUs (compute 7.0+)
logger.info(f"[WHISPER] Using CUDA GPU acceleration (10x faster)")

Check failure on line 2129 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:2129:29: F541 f-string without any placeholders
else:
logger.info(f"[WHISPER] Using CPU (no CUDA GPU detected)")

Check failure on line 2131 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:2131:29: F541 f-string without any placeholders
except ImportError:
logger.info(f"[WHISPER] Using CPU (ctranslate2 not available)")

Check failure on line 2133 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:2133:25: F541 f-string without any placeholders

_whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
_whisper_model_name = model_name
Expand Down Expand Up @@ -2337,7 +2337,7 @@
if sample_path and os.path.exists(sample_path):
try:
os.unlink(sample_path)
except:

Check failure on line 2340 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

app.py:2340:13: E722 Do not use bare `except`
pass

return result
Expand Down Expand Up @@ -8954,6 +8954,24 @@
per_page = request.args.get('per_page', 50, type=int)
offset = (page - 1) * per_page

# Issue #111: Sortable columns
sort_by = request.args.get('sort', '')[:20]
sort_dir = request.args.get('sort_dir', 'asc')
if sort_dir not in ('asc', 'desc'):
sort_dir = 'asc'
sort_direction = 'ASC' if sort_dir == 'asc' else 'DESC'

# Whitelist of sortable columns per table context
BOOK_SORT_COLS = {'author': 'current_author', 'title': 'current_title', 'status': 'status'}
HISTORY_SORT_COLS = {'author': 'h.old_author', 'title': 'h.old_title', 'status': 'h.status', 'date': 'h.fixed_at'}
QUEUE_SORT_COLS = {'author': 'b.current_author', 'title': 'b.current_title', 'priority': 'q.priority'}

def build_order_by(sort_cols, default_order):
"""Build ORDER BY clause from whitelisted sort columns or default."""
if sort_by and sort_by in sort_cols:
return 'ORDER BY ' + sort_cols[sort_by] + ' ' + sort_direction
return 'ORDER BY ' + default_order

items = []

# Get search parameter
Expand Down Expand Up @@ -9042,6 +9060,11 @@

# === FETCH ITEMS based on filter ===
if status_filter == 'orphan':
# Issue #111: Sort orphans if requested
if sort_by == 'author':
orphan_list.sort(key=lambda o: (o.get('author') or '').lower(), reverse=(sort_dir == 'desc'))
elif sort_by == 'title':
orphan_list.sort(key=lambda o: (o.get('detected_title') or '').lower(), reverse=(sort_dir == 'desc'))
# Return orphans as items
for idx, orphan in enumerate(orphan_list[offset:offset + per_page]):
items.append({
Expand All @@ -9058,13 +9081,14 @@

elif status_filter == 'pending':
# Items with pending fixes
order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC')
c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title,
h.old_path, h.new_path, h.status, h.fixed_at, h.error_message,
b.path, b.current_author, b.current_title
FROM history h
JOIN books b ON h.book_id = b.id
WHERE h.status = 'pending_fix'
ORDER BY h.fixed_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9085,12 +9109,13 @@
elif status_filter == 'queue':
# Items in the processing queue
# Issue #36: Filter out series_folder and multi_book_files - they should never appear in queue
order = build_order_by(QUEUE_SORT_COLS, 'q.priority, q.added_at')
c.execute('''SELECT q.id as queue_id, q.reason, q.added_at, q.priority,
b.id as book_id, b.path, b.current_author, b.current_title, b.status
FROM queue q
JOIN books b ON q.book_id = b.id
WHERE b.status NOT IN ('series_folder', 'multi_book_files', 'verified', 'fixed')
ORDER BY q.priority, q.added_at
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9108,13 +9133,14 @@

elif status_filter == 'fixed':
# Successfully fixed items
order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC')
c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title,
h.old_path, h.new_path, h.status, h.fixed_at,
b.path
FROM history h
JOIN books b ON h.book_id = b.id
WHERE h.status = 'fixed'
ORDER BY h.fixed_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9134,10 +9160,11 @@

elif status_filter == 'verified':
# Verified/OK books - include profile for source display
order = build_order_by(BOOK_SORT_COLS, 'updated_at DESC')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, profile, confidence, user_locked
FROM books
WHERE status = 'verified'
ORDER BY updated_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
item = {
Expand Down Expand Up @@ -9168,13 +9195,14 @@

elif status_filter == 'error':
# Error items from history
order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC')
c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title,
h.old_path, h.new_path, h.status, h.fixed_at, h.error_message,
b.path
FROM history h
JOIN books b ON h.book_id = b.id
WHERE h.status IN ('error', 'duplicate', 'corrupt_dest')
ORDER BY h.fixed_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9195,13 +9223,14 @@

elif status_filter == 'attention':
# Items needing attention
order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC')
c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title,
h.old_path, h.new_path, h.status, h.fixed_at, h.error_message,
b.path
FROM history h
JOIN books b ON h.book_id = b.id
WHERE h.status = 'needs_attention'
ORDER BY h.fixed_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9217,9 +9246,11 @@
'error_message': row['error_message']
})
# Also get books with structure issues or watch folder errors
order2 = build_order_by(BOOK_SORT_COLS, 'current_author, current_title')
c.execute('''SELECT id, path, current_author, current_title, status, error_message, source_type
FROM books
WHERE status IN ('needs_attention', 'structure_reversed', 'watch_folder_error')
''' + order2 + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9236,10 +9267,11 @@

elif status_filter == 'locked':
# User-locked books - books where user has manually set metadata
order = build_order_by(BOOK_SORT_COLS, 'updated_at DESC')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked
FROM books
WHERE user_locked = 1
ORDER BY updated_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9255,10 +9287,11 @@

# Issue #53: Media type filters
elif status_filter == 'audiobook_only':
order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type
FROM books
WHERE media_type = 'audiobook' OR media_type IS NULL
ORDER BY current_author, current_title
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9274,10 +9307,11 @@
})

elif status_filter == 'ebook_only':
order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type
FROM books
WHERE media_type = 'ebook'
ORDER BY current_author, current_title
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9293,10 +9327,11 @@
})

elif status_filter == 'both_formats':
order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type
FROM books
WHERE media_type = 'both'
ORDER BY current_author, current_title
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9314,10 +9349,11 @@
elif status_filter == 'search' and search_query:
# Search across all books by author or title
search_pattern = f'%{search_query}%'
order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title')
c.execute('''SELECT id, path, current_author, current_title, status, updated_at, user_locked, media_type
FROM books
WHERE current_author LIKE ? OR current_title LIKE ?
ORDER BY current_author, current_title
''' + order + '''
LIMIT ? OFFSET ?''', (search_pattern, search_pattern, per_page, offset))
for row in c.fetchall():
items.append({
Expand All @@ -9339,12 +9375,13 @@

else: # 'all' - show everything mixed
# Get recent history items (includes pending, fixed, errors)
order = build_order_by(HISTORY_SORT_COLS, 'h.fixed_at DESC')
c.execute('''SELECT h.id, h.book_id, h.old_author, h.old_title, h.new_author, h.new_title,
h.old_path, h.new_path, h.status, h.fixed_at, h.error_message,
b.path, b.current_author, b.current_title, b.user_locked
FROM history h
JOIN books b ON h.book_id = b.id
ORDER BY h.fixed_at DESC
''' + order + '''
LIMIT ? OFFSET ?''', (per_page, offset))
for row in c.fetchall():
item_type = 'pending_fix' if row['status'] == 'pending_fix' else \
Expand Down Expand Up @@ -9412,6 +9449,8 @@
'per_page': per_page,
'total': total,
'total_pages': total_pages,
'sort': sort_by,
'sort_dir': sort_dir,
'skip_confirmations': config.get('skip_confirmations', False)
})

Expand Down
64 changes: 61 additions & 3 deletions templates/library.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@
padding: 2px 8px;
font-size: 0.8rem;
}
/* Issue #111: Sortable column headers */
th[onclick]:hover {
color: #00d9ff;
}
.sort-icon {
font-size: 0.7rem;
opacity: 0.5;
}
.sort-icon.active {
opacity: 1;
color: #00d9ff;
}
</style>

<!-- Filter Chips -->
Expand Down Expand Up @@ -206,9 +218,15 @@
<thead style="position: sticky; top: 0; background: rgba(22, 33, 62, 0.98); z-index: 10;">
<tr>
<th style="width: 3%;"><input type="checkbox" id="select-all" onclick="toggleSelectAll()" title="Select all visible"></th>
<th style="width: 23%;">Author</th>
<th style="width: 29%;">Title</th>
<th style="width: 14%;">Status</th>
<th style="width: 23%; cursor: pointer; user-select: none;" onclick="toggleSort('author')" title="Sort by author">
Author <span class="sort-icon" id="sort-icon-author"></span>
</th>
<th style="width: 29%; cursor: pointer; user-select: none;" onclick="toggleSort('title')" title="Sort by title">
Title <span class="sort-icon" id="sort-icon-title"></span>
</th>
<th style="width: 14%; cursor: pointer; user-select: none;" onclick="toggleSort('status')" title="Sort by status">
Status <span class="sort-icon" id="sort-icon-status"></span>
</th>
<th style="width: 18%;">Details</th>
<th style="width: 13%;">Actions</th>
</tr>
Expand Down Expand Up @@ -348,6 +366,8 @@ <h5 class="modal-title"><i class="bi bi-pencil"></i> Edit Book Metadata</h5>
<script>
let currentFilter = 'all';
let currentPage = 1;
let currentSort = '';
let currentSortDir = 'asc';
let libraryData = null;
let skipConfirmations = {{ 'true' if config.get('skip_confirmations') else 'false' }};
let isProcessing = false;
Expand Down Expand Up @@ -381,6 +401,38 @@ <h5 class="modal-title"><i class="bi bi-pencil"></i> Edit Book Metadata</h5>

let currentSearch = '';

// Issue #111: Sort column toggle
function toggleSort(column) {
if (currentSort === column) {
// Same column: toggle direction, or clear on third click
if (currentSortDir === 'asc') {
currentSortDir = 'desc';
} else {
currentSort = '';
currentSortDir = 'asc';
}
} else {
currentSort = column;
currentSortDir = 'asc';
}
currentPage = 1;
loadLibrary();
}

function updateSortIcons() {
document.querySelectorAll('.sort-icon').forEach(icon => {
icon.textContent = '';
icon.classList.remove('active');
});
if (currentSort) {
const icon = document.getElementById(`sort-icon-${currentSort}`);
if (icon) {
icon.textContent = currentSortDir === 'asc' ? '\u25B2' : '\u25BC';
icon.classList.add('active');
}
}
}

function loadLibrary() {
const tbody = document.getElementById('items-table');
tbody.innerHTML = `
Expand All @@ -396,13 +448,19 @@ <h5 class="modal-title"><i class="bi bi-pencil"></i> Edit Book Metadata</h5>
if (currentSearch) {
url += `&search=${encodeURIComponent(currentSearch)}`;
}
if (currentSort) {
url += `&sort=${currentSort}&sort_dir=${currentSortDir}`;
}
fetch(url)
.then(r => r.json())
.then(data => {
libraryData = data;
currentSort = data.sort || '';
currentSortDir = data.sort_dir || 'asc';
updateCounts(data.counts);
renderItems(data.items);
renderPagination(data.page, data.total_pages);
updateSortIcons();

// Show/hide contextual buttons (with null checks for robustness)
const btnApplyAll = document.getElementById('btn-apply-all');
Expand Down