Add authors pages and requestType notifications

Introduce full authors browsing/detail feature and enhance notifications to support type-specific titles.

- Add server APIs: authors search, author detail, and author books routes (audnexus integration) that require auth and enrich results with library matches.
- Add frontend pages/components: /authors listing and /authors/[asin] detail pages; AuthorCard, AuthorGrid, AuthorDetailCard, SimilarAuthorsRow, and related skeletons.
- Add hook and integration stubs: new useAuthors hook and audnexus-authors integration; update audible service to expose audibleBaseUrl.
- Update AudiobookDetailsModal to use audibleBaseUrl and link author names to author detail pages.
- Add header navigation link to Authors.
- Notifications: extend docs and code to include requestType (audiobook|ebook), add getEventTitle/getEventMeta helpers, update queue signature and providers/processors/tests to pass/handle requestType so titles can be resolved per request type.
- Misc: job queue, processors, provider tests and notification tests updated to reflect new behavior.

This change enables browsing authors and provides type-aware notification titles without per-provider changes.
This commit is contained in:
kikootwo
2026-02-12 15:21:42 -05:00
parent e40e77c8fe
commit 89422fc77a
33 changed files with 1629 additions and 40 deletions
+26 -7
View File
@@ -13,11 +13,12 @@ export type NotificationPriority = 'normal' | 'high';
* Central registry of notification events.
*
* Each entry defines:
* - `label`: Human-readable name shown in the UI
* - `title`: Title used in notification messages
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
* - `label`: Human-readable name shown in the UI
* - `title`: Default title used in notification messages
* - `titleByRequestType`: Optional map of request-type-specific titles (e.g. audiobook → "Audiobook Available")
* - `emoji`: Emoji prefix for notification titles
* - `severity`: Drives provider formatting (colors, Apprise types, ntfy tags)
* - `priority`: Drives notification urgency (Pushover/ntfy priority levels)
*/
export const NOTIFICATION_EVENTS = {
request_pending_approval: {
@@ -35,8 +36,12 @@ export const NOTIFICATION_EVENTS = {
priority: 'normal' as const,
},
request_available: {
label: 'Audiobook Available',
title: 'Audiobook Available',
label: 'Request Available',
title: 'Request Available',
titleByRequestType: {
audiobook: 'Audiobook Available',
ebook: 'Ebook Available',
} as Record<string, string>,
emoji: '\u{1F389}',
severity: 'success' as const,
priority: 'high' as const,
@@ -71,6 +76,20 @@ export function getEventMeta(event: NotificationEvent) {
return NOTIFICATION_EVENTS[event];
}
/**
* Helper: get the resolved notification title for an event.
* If the event has a `titleByRequestType` map and a matching requestType is provided,
* returns the type-specific title. Otherwise falls back to the default `title`.
*/
export function getEventTitle(event: NotificationEvent, requestType?: string): string {
const meta = NOTIFICATION_EVENTS[event];
if (requestType && 'titleByRequestType' in meta) {
const typeTitle = (meta as typeof meta & { titleByRequestType: Record<string, string> }).titleByRequestType[requestType];
if (typeTitle) return typeTitle;
}
return meta.title;
}
/** Helper: get the human-readable label for an event */
export function getEventLabel(event: NotificationEvent): string {
return NOTIFICATION_EVENTS[event].label;
+2
View File
@@ -12,6 +12,7 @@ export interface Audiobook {
asin: string;
title: string;
author: string;
authorAsin?: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
@@ -81,6 +82,7 @@ export function useAudiobookDetails(asin: string | null) {
return {
audiobook: data?.audiobook || null,
audibleBaseUrl: data?.audibleBaseUrl || 'https://www.audible.com',
isLoading,
error,
};
+88
View File
@@ -0,0 +1,88 @@
/**
* Component: Authors Fetching Hooks
* Documentation: documentation/frontend/components.md
*/
'use client';
import useSWR from 'swr';
import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks';
export interface Author {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similarCount: number;
}
export interface SimilarAuthor {
asin: string;
name: string;
image?: string;
}
export interface AuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
genres: string[];
similar: SimilarAuthor[];
audibleUrl?: string;
}
export function useAuthorSearch(name: string) {
const shouldFetch = name && name.length > 0;
const endpoint = shouldFetch
? `/api/authors/search?name=${encodeURIComponent(name)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 30000,
});
return {
authors: (data?.authors || []) as Author[],
query: data?.query || '',
isLoading: shouldFetch && isLoading,
error,
};
}
export function useAuthorDetail(asin: string | null) {
const endpoint = asin ? `/api/authors/${asin}` : null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 300000, // Cache for 5 minutes
});
return {
author: (data?.author || null) as AuthorDetail | null,
isLoading,
error,
};
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const shouldFetch = asin && authorName;
const endpoint = shouldFetch
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}`
: null;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
});
return {
books: (data?.books || []) as Audiobook[],
totalBooks: data?.totalBooks || 0,
isLoading: !!shouldFetch && isLoading,
error,
};
}
+158 -1
View File
@@ -30,6 +30,7 @@ export interface AudibleAudiobook {
asin: string;
title: string;
author: string;
authorAsin?: string;
narrator?: string;
description?: string;
coverArtUrl?: string;
@@ -61,6 +62,13 @@ export class AudibleService {
// Client will be created lazily on first use
}
/**
* Get the current Audible base URL for the configured region
*/
public getBaseUrl(): string {
return this.baseUrl;
}
/**
* Force re-initialization (used when region config changes)
*/
@@ -269,6 +277,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').eq(1).text().trim();
@@ -281,6 +293,7 @@ export class AudibleService {
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
@@ -367,6 +380,10 @@ export class AudibleService {
const authorText = $el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link if available
const authorHref = $el.find('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
const narratorText = $el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
@@ -378,6 +395,7 @@ export class AudibleService {
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
rating,
@@ -454,10 +472,15 @@ export class AudibleService {
$el.find('.bc-heading a').text().trim();
// Extract author from author link
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
const authorLink = $el.find('a[href*="/author/"]').first();
const authorText = authorLink.text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
// Extract author ASIN from author link href
const authorHref = authorLink.attr('href') || '';
const authorAsinMatch = authorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
// Extract narrator from narrator search link
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
@@ -478,6 +501,7 @@ export class AudibleService {
asin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin: authorAsinMatch?.[1] || undefined,
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
@@ -510,6 +534,129 @@ export class AudibleService {
}
}
/**
* Search for all books by a specific author, validated by ASIN.
* Uses Audible's searchAuthor parameter and paginates through all results.
* Filters: (1) author link must contain the target ASIN, (2) language must be English.
*/
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> {
await this.initialize();
const MAX_PAGES = 10;
const allBooks: AudibleAudiobook[] = [];
const seenAsins = new Set<string>();
try {
logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`);
for (let page = 1; page <= MAX_PAGES; page++) {
const { data: response, meta } = await this.fetchWithRetry('/search', {
params: {
ipRedirectOverride: 'true',
searchAuthor: authorName,
pageSize: AUDIBLE_PAGE_SIZE,
page,
},
});
const $ = cheerio.load(response.data);
let pageResults = 0;
$('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element);
// --- Language filter: require explicit "English" ---
const langText = $el.find('span:contains("Language:")').text().trim() ||
$el.find('.languageLabel').text().trim();
// Extract language value (e.g. "Language: English" → "English")
const langMatch = langText.match(/Language:\s*(.+)/i);
const language = langMatch?.[1]?.trim();
if (!language || language.toLowerCase() !== 'english') return;
// --- Author ASIN filter: verify target ASIN in author links ---
const authorLinks = $el.find('a[href*="/author/"]');
let hasMatchingAuthor = false;
authorLinks.each((_i, link) => {
const href = $(link).attr('href') || '';
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (asinMatch && asinMatch[1] === authorAsin) {
hasMatchingAuthor = true;
return false; // break .each()
}
});
if (!hasMatchingAuthor) return;
// --- Extract book ASIN ---
const bookAsin = $el.find('li').attr('data-asin') ||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
if (!bookAsin || seenAsins.has(bookAsin)) return;
seenAsins.add(bookAsin);
// --- Parse book details ---
const title = $el.find('h2').first().text().trim() ||
$el.find('h3 a').text().trim() ||
$el.find('.bc-heading a').text().trim();
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
$el.find('.authorLabel').text().trim() ||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
$el.find('.narratorLabel').text().trim();
const coverArtUrl = $el.find('img').attr('src') || '';
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
$el.find('span:contains("Length:")').text().trim();
const durationMinutes = this.parseRuntime(runtimeText);
const ratingText = $el.find('.ratingsLabel').text().trim() ||
$el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
allBooks.push({
asin: bookAsin,
title,
author: authorText.replace('By:', '').replace('Written by:', '').trim(),
authorAsin,
narrator: narratorText.replace('Narrated by:', '').trim(),
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
durationMinutes,
rating,
});
pageResults++;
});
// Check if there are more pages
const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE;
logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`);
if (!hasMore || pageResults === 0) break;
// Pace between pages
if (page < MAX_PAGES) {
await this.delay(this.pacer.reportPageResult(meta));
}
}
logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`);
return allBooks;
} catch (error) {
logger.error(`Author books search failed for "${authorName}"`, {
error: error instanceof Error ? error.message : String(error),
collectedSoFar: allBooks.length,
});
// Return what we collected before the error
return allBooks;
}
}
/**
* Get detailed audiobook information
* Primary: Audnexus API (reliable, structured data)
@@ -563,6 +710,7 @@ export class AudibleService {
asin,
title: data.title || '',
author: data.authors?.map((a: any) => a.name).join(', ') || '',
authorAsin: data.authors?.[0]?.asin || undefined,
narrator: data.narrators?.map((n: any) => n.name).join(', ') || '',
description: data.description || data.summary || '',
coverArtUrl: data.image || '',
@@ -723,6 +871,15 @@ export class AudibleService {
logger.info(` Author from HTML: "${result.author}"`);
}
// Author ASIN - extract from the first author link
if (!result.authorAsin) {
const firstAuthorHref = $('a[href*="/author/"]').first().attr('href') || '';
const authorAsinMatch = firstAuthorHref.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
if (authorAsinMatch) {
result.authorAsin = authorAsinMatch[1];
}
}
// Narrator - try multiple approaches (only in product details area)
if (!result.narrator) {
// Look specifically in the product details section
+104
View File
@@ -0,0 +1,104 @@
/**
* Component: Audnexus Author API Integration
* Documentation: documentation/integrations/audible.md
*
* Shared utilities for fetching author data from the Audnexus API.
* Used by author search, author detail, and similar authors routes.
*/
import axios from 'axios';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('Audnexus.Authors');
const AUDNEXUS_BASE = 'https://api.audnex.us';
const AUDNEXUS_TIMEOUT = 10000;
const AUDNEXUS_HEADERS = { 'User-Agent': 'ReadMeABook/1.0' };
export interface AudnexusAuthorSearch {
asin: string;
name: string;
}
export interface AudnexusAuthorGenre {
asin: string;
name: string;
type: string;
}
export interface AudnexusAuthorSimilar {
asin: string;
name: string;
}
export interface AudnexusAuthorDetail {
asin: string;
name: string;
description?: string;
image?: string;
region: string;
genres?: AudnexusAuthorGenre[];
similar?: AudnexusAuthorSimilar[];
}
/**
* Fetch with retry and exponential backoff for Audnexus API
*/
export async function audnexusFetchWithRetry(url: string, params: Record<string, string>, maxRetries = 3): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios.get(url, {
params,
timeout: AUDNEXUS_TIMEOUT,
headers: AUDNEXUS_HEADERS,
});
} catch (error: any) {
lastError = error;
const status = error.response?.status;
const isRetryable = !status || status === 503 || status === 429 || status >= 500;
if (!isRetryable) throw error;
if (attempt === maxRetries) break;
const backoffMs = Math.pow(2, attempt) * 1000;
logger.info(`Audnexus request failed (${status || 'network error'}), retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, backoffMs));
}
}
throw lastError || new Error('Audnexus request failed after retries');
}
/**
* Search authors via Audnexus and return deduplicated results
*/
export async function searchAuthors(name: string, region: string): Promise<AudnexusAuthorSearch[]> {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors`, { region, name });
const results: AudnexusAuthorSearch[] = response.data;
const seen = new Set<string>();
return results.filter(author => {
if (seen.has(author.asin)) return false;
seen.add(author.asin);
return true;
});
}
/**
* Fetch full author details from Audnexus
*/
export async function fetchAuthorDetail(asin: string, region: string): Promise<AudnexusAuthorDetail | null> {
try {
const response = await audnexusFetchWithRetry(`${AUDNEXUS_BASE}/authors/${asin}`, { region });
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
logger.debug(`Author not found on Audnexus: ${asin}`);
} else {
logger.warn(`Failed to fetch author detail: ${asin}`, { error: error.message });
}
return null;
}
}
@@ -622,7 +622,9 @@ async function processEbookOrganization(
requestId,
book.title,
book.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'ebook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
@@ -325,7 +325,9 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
+3 -1
View File
@@ -514,7 +514,9 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
request.user.plexUsername || 'Unknown User',
undefined,
'audiobook'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
@@ -18,7 +18,7 @@ export type { SendNotificationPayload } from '../services/job-queue.service';
* Calls NotificationService to send notifications to all enabled backends
*/
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
const { event, requestId, issueId, title, author, userName, message, jobId } = payload;
const { event, requestId, issueId, title, author, userName, message, requestType, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SendNotification');
@@ -34,6 +34,7 @@ export async function processSendNotification(payload: SendNotificationPayload):
author,
userName,
message,
requestType,
timestamp: new Date(),
});
+4 -1
View File
@@ -155,6 +155,7 @@ export interface SendNotificationPayload extends JobPayload {
author: string;
userName: string;
message?: string;
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific notification titles
timestamp: Date;
}
@@ -948,7 +949,8 @@ export class JobQueueService {
title: string,
author: string,
userName: string,
message?: string
message?: string,
requestType?: string
): Promise<string> {
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
return await this.addJob(
@@ -963,6 +965,7 @@ export class JobQueueService {
author,
userName,
message,
requestType,
// Pass the original ID for notification display (e.g., Discord footer)
...(event === 'issue_reported' && { issueId: requestId }),
timestamp: new Date(),
@@ -18,6 +18,7 @@ export interface NotificationPayload {
author: string;
userName: string;
message?: string; // For error/issue events
requestType?: string; // 'audiobook' | 'ebook' — drives type-specific titles via getEventTitle()
timestamp: Date;
}
@@ -4,7 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface AppriseConfig {
serverUrl: string;
@@ -108,8 +108,7 @@ export class AppriseProvider implements INotificationProvider {
}
private formatMessage(payload: NotificationPayload): { title: string; body: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
const { event, title, author, userName, message, requestType } = payload;
const isIssue = event === 'issue_reported';
const messageLines = [
@@ -123,7 +122,7 @@ export class AppriseProvider implements INotificationProvider {
}
return {
title: meta.title,
title: getEventTitle(event, requestType),
body: messageLines.join('\n'),
};
}
@@ -4,7 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity } from '@/lib/constants/notification-events';
import { getEventMeta, getEventTitle, type NotificationSeverity } from '@/lib/constants/notification-events';
export interface DiscordConfig {
webhookUrl: string;
@@ -59,8 +59,9 @@ export class DiscordProvider implements INotificationProvider {
}
private formatEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const { event, title, author, userName, message, requestId, requestType, timestamp } = payload;
const meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported';
const fields = [
@@ -74,7 +75,7 @@ export class DiscordProvider implements INotificationProvider {
}
return {
title: `${meta.emoji} ${meta.title}`,
title: `${meta.emoji} ${resolvedTitle}`,
color: SEVERITY_COLORS[meta.severity],
fields,
footer: {
@@ -4,7 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
import { getEventMeta, getEventTitle, type NotificationSeverity, type NotificationPriority } from '@/lib/constants/notification-events';
export interface NtfyConfig {
serverUrl?: string;
@@ -83,8 +83,7 @@ export class NtfyProvider implements INotificationProvider {
}
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
const meta = getEventMeta(event);
const { event, title, author, userName, message, requestType } = payload;
const isIssue = event === 'issue_reported';
const messageLines = [
@@ -98,7 +97,7 @@ export class NtfyProvider implements INotificationProvider {
}
return {
title: meta.title,
title: getEventTitle(event, requestType),
message: messageLines.join('\n'),
};
}
@@ -4,7 +4,7 @@
*/
import { INotificationProvider, NotificationPayload, ProviderMetadata } from '../INotificationProvider';
import { getEventMeta, type NotificationPriority } from '@/lib/constants/notification-events';
import { getEventMeta, getEventTitle, type NotificationPriority } from '@/lib/constants/notification-events';
export interface PushoverConfig {
userKey: string;
@@ -77,12 +77,13 @@ export class PushoverProvider implements INotificationProvider {
}
private formatMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
const { event, title, author, userName, message, requestType } = payload;
const meta = getEventMeta(event);
const resolvedTitle = getEventTitle(event, requestType);
const isIssue = event === 'issue_reported';
const messageLines = [
`${meta.emoji} ${meta.title}`,
`${meta.emoji} ${resolvedTitle}`,
'',
`\u{1F4DA} ${title}`,
`\u270D\uFE0F ${author}`,
@@ -94,7 +95,7 @@ export class PushoverProvider implements INotificationProvider {
}
return {
title: meta.title,
title: resolvedTitle,
message: messageLines.join('\n'),
};
}