From f8841d4ceecf10bc2421665309326b9ca089456b Mon Sep 17 00:00:00 2001 From: Keenan Nicholson Date: Mon, 25 Dec 2023 00:34:12 -0500 Subject: [PATCH] Initial work on issue #6. Most interactions with the audible CLI now result in whether or not the CLI command was successful. Added a function to check authentication. --- .../server/cmd/audible/cmd/download/index.ts | 3 + .../server/cmd/audible/cmd/library/index.ts | 111 ++++++++++++++---- .../server/cmd/audible/cmd/profile/index.ts | 7 +- src/lib/server/cmd/audible/types/index.ts | 32 +++++ src/lib/server/cmd/index.ts | 12 +- src/lib/types/index.ts | 7 +- .../sources/audible/[id]/+page.server.ts | 90 +++++++++++--- src/routes/sources/audible/[id]/+page.svelte | 12 +- 8 files changed, 224 insertions(+), 50 deletions(-) diff --git a/src/lib/server/cmd/audible/cmd/download/index.ts b/src/lib/server/cmd/audible/cmd/download/index.ts index 6c7e290..3d498e8 100644 --- a/src/lib/server/cmd/audible/cmd/download/index.ts +++ b/src/lib/server/cmd/audible/cmd/download/index.ts @@ -163,6 +163,9 @@ export const download = async ( if (data.indexOf('audible.exceptions.NetworkError') !== -1) { global.audible.cancelMap[asin].error = BookDownloadError.NETWORK_ERROR; audible.kill(); + } else if (data.indexOf('audible.exceptions.Unauthorized: Forbidden (403)') !== -1) { + global.audible.cancelMap[asin].error = BookDownloadError.NOT_AUTHORIZED; + audible.kill(); } }; diff --git a/src/lib/server/cmd/audible/cmd/library/index.ts b/src/lib/server/cmd/audible/cmd/library/index.ts index c551af0..ace6691 100644 --- a/src/lib/server/cmd/audible/cmd/library/index.ts +++ b/src/lib/server/cmd/audible/cmd/library/index.ts @@ -1,7 +1,7 @@ import * as child_process from 'node:child_process'; import * as fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; -import type { Library, BookFromCLI } from '../../types'; +import { type Library, type BookFromCLI, type BookDownloadError, CLIError } from '../../types'; import { Event, SourceType } from '$lib/types'; import { isLocked } from '../../'; import prisma from '$lib/server/prisma'; @@ -300,15 +300,65 @@ const processBook = async (book: BookFromCLI, id: string): Promise => { return true; }; +/** + * Check that an Audible profile is authenticated + * @param id the ID of the source to check + * @returns whether or not it is authenticated, or null if unknown + */ +export const checkAuthenticated = async (id: string): Promise => { + // Check that the ID was actually submitted + if (id === null || id === undefined) return null; + + // Get the profile from the database + const source = await prisma.source.findUnique({ + where: { + type: SourceType.AUDIBLE, + NOT: { audible: null }, + id + }, + include: { audible: true } + }); + + // Return if the profile was not found + if (source === null || source === undefined || source.audible === null || isLocked()) return null; + + // Make sure the config file is written + await writeConfigFile(); + + events.emitProgress('basic.account.sync', id, { + t: Event.Progress.Basic.Stage.START + }); + + const cli_id = source.audible.cli_id; + try { + await new Promise((resolve, reject) => { + child_process.exec( + `${AUDIBLE_CMD} -P ${cli_id} api /1.0/account/information`, + { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, + (err, stdout) => { + if (err !== null) reject(stdout); + else resolve() + } + ); + }); + return true; + } catch (e) { + const err = e as string; + console.log('ERR internal error, not authenticated'); + console.log(e); + return false; + } +} + /** * Parse a library JSON file and import all the books to the DB * @param id the account to associate the book with */ export const get = async ( id: string -): Promise<{ numCreated: number; numUpdated: number } | null> => { +): Promise<{ err: CLIError, results?: { numCreated: number; numUpdated: number }}> => { // Check that the ID was actually submitted - if (id === null || id === undefined) return null; + if (id === null || id === undefined) return { err: CLIError.NO_ID }; // Get the profile from the database const source = await prisma.source.findUnique({ @@ -321,7 +371,7 @@ export const get = async ( }); // Return if the profile was not found - if (source === null || source === undefined || source.audible === null || isLocked()) return null; + if (source === null || source === undefined || source.audible === null || isLocked()) return { err: CLIError.NO_SOURCE }; // Create a temp directory for this library if (!fs.existsSync(`/tmp`)) fs.mkdirSync(`/tmp`); @@ -336,20 +386,39 @@ export const get = async ( try { const cli_id = source.audible.cli_id; - await new Promise((resolve) => { - child_process.exec( - `${AUDIBLE_CMD} -P ${cli_id} library export --format json -o /tmp/${cli_id}.library.json`, - { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, - () => resolve() - ); - }); - await new Promise((resolve) => { - child_process.exec( - `${AUDIBLE_CMD} -P ${cli_id} library export --format tsv -o /db/audible/${cli_id}.library.tsv`, - { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, - () => resolve() - ); - }); + try { + await new Promise((resolve, reject) => { + child_process.exec( + `${AUDIBLE_CMD} -P ${cli_id} library export --format json -o /tmp/${cli_id}.library.json`, + { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, + (err, stdout) => { + if (err !== null) reject(stdout); + else resolve() + } + ); + }); + await new Promise((resolve, reject) => { + child_process.exec( + `${AUDIBLE_CMD} -P ${cli_id} library export --format tsv -o /db/audible/${cli_id}.library.tsv`, + { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, + (err, stdout) => { + if (err !== null) reject(stdout); + else resolve() + } + ); + }); + } catch (e) { + const err = e as string; + console.log('ERR internal error'); + console.log(e); + if (err.indexOf('audible.exceptions.Unauthorized: Forbidden (403)') !== -1) { + return { err: CLIError.NOT_AUTHORIZED }; + } else if (err.indexOf('audible.exceptions.NetworkError: Network down.') !== -1) { + return { err: CLIError.NETWORK_ERROR }; + } else { + return { err: 'UNKNOWN' as CLIError }; + } + } const library = JSON.parse( fs.readFileSync(`/tmp/${cli_id}.library.json`).toString() @@ -386,10 +455,10 @@ export const get = async ( success: true }); - return { numCreated, numUpdated }; + return { err: CLIError.NO_ERROR, results: { numCreated, numUpdated } }; } catch (e) { // Didn't work - console.log('ERR'); + console.log('ERR', e); const err = e as { stdout: Buffer }; console.log(err); console.log(err.stdout.toString()); @@ -402,6 +471,6 @@ export const get = async ( try { fs.rmSync(`/tmp/${id}.library.json`, { recursive: true, force: true }); } catch (e) {} - return null; + return { err: 'UNKNOWN' as CLIError }; } }; diff --git a/src/lib/server/cmd/audible/cmd/profile/index.ts b/src/lib/server/cmd/audible/cmd/profile/index.ts index ed1cc74..478ef47 100644 --- a/src/lib/server/cmd/audible/cmd/profile/index.ts +++ b/src/lib/server/cmd/audible/cmd/profile/index.ts @@ -145,11 +145,14 @@ export const fetchMetadata = async ( try { // Have the audible-cli get the activation bytes - await new Promise((resolve) => { + await new Promise((resolve, reject) => { child_process.exec( `${AUDIBLE_CMD} -P ${source.audible?.cli_id} activation-bytes`, { env: { AUDIBLE_CONFIG_DIR: AUDIBLE_FOLDER } }, - () => resolve() + (err, stdout) => { + if (err !== null) reject(stdout); + else resolve() + } ); }); // Get the auth file associated with this profile diff --git a/src/lib/server/cmd/audible/types/index.ts b/src/lib/server/cmd/audible/types/index.ts index f0ada8d..703fb3f 100644 --- a/src/lib/server/cmd/audible/types/index.ts +++ b/src/lib/server/cmd/audible/types/index.ts @@ -166,6 +166,7 @@ export enum BookDownloadError { BOOK_NOT_FOUND = 'BOOK_NOT_FOUND', CANCELED = 'CANCELED', NETWORK_ERROR = 'NETWORK_ERROR', + NOT_AUTHORIZED = 'NOT_AUTHORIZED', NO_PROFILE = 'NO_PROFILE' } @@ -183,9 +184,40 @@ export const bookDownloadErrorToString = (e: BookDownloadError): string => { return 'The book download process was canceled'; case BookDownloadError.NETWORK_ERROR: return 'A network issue exists that is preventing download'; + case BookDownloadError.NOT_AUTHORIZED: + return 'This account is not authorized by Audible'; case BookDownloadError.NO_PROFILE: return 'No profile exists to download this book'; default: return 'An unknown error occurred'; } }; + + +export enum CLIError { + NO_ERROR = 'NO_ERROR', + AUDIBLE_LOCKED = 'AUDIBLE_LOCKED', + NO_ID = 'NO_ID', + NO_SOURCE = 'NO_SOURCE', + NETWORK_ERROR = 'NETWORK_ERROR', + NOT_AUTHORIZED = 'NOT_AUTHORIZED', +} + +export const cliErrorToString = (e: CLIError): string => { + switch (e) { + case CLIError.NO_ERROR: + return 'No error'; + case CLIError.AUDIBLE_LOCKED: + return 'The audible CLI is locked'; + case CLIError.NO_ID: + return 'No ID was submitted'; + case CLIError.NO_SOURCE: + return 'No Audible source provided'; + case CLIError.NETWORK_ERROR: + return 'There was a network error'; + case CLIError.NOT_AUTHORIZED: + return 'This account is not authorized by Audible'; + default: + return 'An unknown error occurred'; + } +}; diff --git a/src/lib/server/cmd/index.ts b/src/lib/server/cmd/index.ts index 1956fef..4f436d5 100644 --- a/src/lib/server/cmd/index.ts +++ b/src/lib/server/cmd/index.ts @@ -6,7 +6,7 @@ import * as tools from './tools'; import * as settings from '$lib/server/settings'; import * as types from '$lib/types'; import { v4 as uuidv4 } from 'uuid'; -import { BookDownloadError } from './audible/types'; +import { BookDownloadError, CLIError, cliErrorToString } from './audible/types'; import type { Issuer, ModalTheme } from '$lib/types'; import { ConversionError } from './AAXtoMP3/types'; import { ProcessError } from '$lib/types'; @@ -921,16 +921,16 @@ export namespace Cron { // Sync the profile const results = await audible.cmd.library.get(source.id); - if (results !== null) { + if (results.err === CLIError.NO_ERROR && results.results !== undefined) { cronRecord.libSync++; - cronRecord.booksAdded += results.numCreated; - cronRecord.booksUpdated += results.numUpdated; + cronRecord.booksAdded += results.results.numCreated; + cronRecord.booksUpdated += results.results.numUpdated; } // Check if the sync worked if (debug) { - if (results !== null) console.log(JSON.stringify(results)); - else console.log('Sync failed'); + if (results.err === CLIError.NO_ERROR) console.log(JSON.stringify(results)); + else console.log('Sync failed: ', cliErrorToString(results.err)); } } else { if (debug) diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index a7fad60..c198746 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -225,7 +225,8 @@ export enum ProcessError { DESTINATION_NOT_WRITABLE = 'DESTINATION_NOT_WRITABLE', INVALID_FILE = 'INVALID_FILE', CONVERSION_ERROR = 'CONVERSION_ERROR', - COULD_NOT_SAVE = 'COULD_NOT_SAVE' + COULD_NOT_SAVE = 'COULD_NOT_SAVE', + NOT_AUTHORIZED = 'NOT_AUTHORIZED', } export const processErrorToStringShort = (p: ProcessError) => { @@ -254,6 +255,8 @@ export const processErrorToStringShort = (p: ProcessError) => { return 'Conversion Error'; case ProcessError.COULD_NOT_SAVE: return 'Save Error'; + case ProcessError.NOT_AUTHORIZED: + return 'Not Authorized'; default: return 'Unknown error'; } @@ -285,6 +288,8 @@ export const processErrorToStringLong = (p: ProcessError) => { return "Something went wrong while converting this book's audio file."; case ProcessError.COULD_NOT_SAVE: return "Something went wrong while copying this book's audio file to storage."; + case ProcessError.NOT_AUTHORIZED: + return "This account source is not authorized. Please sign-in again."; default: return 'An unknown and unexpected error has occurred.'; } diff --git a/src/routes/sources/audible/[id]/+page.server.ts b/src/routes/sources/audible/[id]/+page.server.ts index 1a13aa4..f1a6efc 100644 --- a/src/routes/sources/audible/[id]/+page.server.ts +++ b/src/routes/sources/audible/[id]/+page.server.ts @@ -8,6 +8,8 @@ import { icons } from '$lib/components'; import * as serverHelpers from '$lib/server/helpers'; import * as events from '$lib/server/events'; import { SourceType, type Issuer, type ModalTheme, type Notification } from '$lib/types'; +import { CLIError, cliErrorToString } from '$lib/server/cmd/audible/types/index.js'; +import { checkAuthenticated } from '$lib/server/cmd/audible/cmd/library'; /** @type {import('./$types').PageServerLoad} */ export const load = async ({ params, fetch }) => { @@ -44,6 +46,9 @@ export const load = async ({ params, fetch }) => { return { source, + promise: { + authenticated: checkAuthenticated(id), + }, tz: await settings.get('general.timezone') }; }; @@ -124,26 +129,75 @@ export const actions = { const results = await audible.cmd.library.get(id); - const notification: Notification = { - id: uuidv4(), - icon_color: null, - icon_path: null, - issuer: 'account.sync' satisfies Issuer, - theme: 'info' satisfies ModalTheme, - text: 'Synced at ' + new Date().toISOString(), - sub_text: null, - identifier: null, - linger_time: 10000, - needs_clearing: true, - auto_open: false - }; - await prisma.notification.create({ data: notification }); - events.emit('notification.created', [notification]); - - if (results !== null) { + // const notification: Notification = { + // id: uuidv4(), + // icon_color: null, + // icon_path: null, + // issuer: 'account.sync' satisfies Issuer, + // theme: 'info' satisfies ModalTheme, + // text: 'Synced at ' + new Date().toISOString(), + // sub_text: null, + // identifier: null, + // linger_time: 10000, + // needs_clearing: true, + // auto_open: false + // }; + // await prisma.notification.create({ data: notification }); + // events.emit('notification.created', [notification]); + + if (results.err === CLIError.NO_ERROR) { + const notification: Notification = { + id: uuidv4(), + icon_color: null, + icon_path: null, + issuer: 'account.sync' satisfies Issuer, + theme: 'info' satisfies ModalTheme, + text: 'Synced at ' + new Date().toISOString(), + sub_text: null, + identifier: null, + linger_time: 10000, + needs_clearing: false, + auto_open: true + }; + await prisma.notification.create({ data: notification }); + events.emit('notification.created', [notification]); return { response: 'sync', success: true, results }; } else { - return { response: 'sync', success: false, message: '' }; + if (results.err === CLIError.NOT_AUTHORIZED) { + const notification: Notification = { + id: uuidv4(), + icon_color: null, + icon_path: null, + issuer: 'account.sync' satisfies Issuer, + theme: 'info' satisfies ModalTheme, + text: `Error: Login expired. Please login again.`, + sub_text: null, + identifier: null, + linger_time: 10000, + needs_clearing: false, + auto_open: true + }; + await prisma.notification.create({ data: notification }); + events.emit('notification.created', [notification]); + return { response: 'sync', success: false, message: 'Login expired. Please login again.' }; + } else { + const notification: Notification = { + id: uuidv4(), + icon_color: null, + icon_path: null, + issuer: 'account.sync' satisfies Issuer, + theme: 'error' satisfies ModalTheme, + text: 'Error syncing: ' + cliErrorToString(results.err), + sub_text: null, + identifier: null, + linger_time: 10000, + needs_clearing: false, + auto_open: true + }; + await prisma.notification.create({ data: notification }); + events.emit('notification.created', [notification]); + return { response: 'sync', success: false, message: cliErrorToString(results.err) }; + } } }, auto_sync: async ({ request, params }) => { diff --git a/src/routes/sources/audible/[id]/+page.svelte b/src/routes/sources/audible/[id]/+page.svelte index df0c406..7e4b947 100644 --- a/src/routes/sources/audible/[id]/+page.svelte +++ b/src/routes/sources/audible/[id]/+page.svelte @@ -29,7 +29,7 @@ } else { console.log('Form failure!'); if (form?.response === 'sync') { - showAlert('Profile sync error', { subText: form?.message, theme: 'error' }); + // showAlert('Profile sync error', { subText: form?.message, theme: 'error' }); } else if (form?.response === 'deregister') { showAlert('Source deletion error', { subText: form?.message, theme: 'error' }); } @@ -193,13 +193,16 @@ : intlFormatDistance(new Date(data.source.last_sync * 1000), new Date()); let lastSyncSpecific = data.source.last_sync === null ? 'Never' : toISOStringTZ(data.source.last_sync * 1000, data.tz); - setInterval(() => { + let interval = setInterval(() => { lastSyncPretty = data.source.last_sync === null ? 'Never' : intlFormatDistance(new Date(data.source.last_sync * 1000), new Date()); }, 1000); + // Clear interval for last-synced + onMount(() => clearInterval(interval)); + const resetProfileDataForm = () => { name = ''; email = ''; @@ -302,6 +305,11 @@ downloaded = new helpers.RunTime({ min: downloaded_run_time_min }); } +{#await data.promise.authenticated} + Loading +{:then data} + {data} +{/await}