audiobookshelf/server/migrations/v2.36.0-add-finished-dates.js
Barnabas 25577f5eea Add finishedDates history to keep multiple finished dates per book
Adds a finishedDates JSON column to mediaProgresses that records a
timestamp for every finish event, so re-listening to a book no longer
loses the previous finished date.

- Marking finished (manually or automatically) appends to finishedDates
- Explicitly un-marking finished removes the most recent entry
- Re-listening to a finished item keeps the history
- Migration backfills finishedDates from the existing finishedAt
- finishedDates is exposed on media progress API responses
- Year in review stats now include books re-finished in later years
- Item page shows previous finished dates with a tooltip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:21:51 -06:00

91 lines
4.1 KiB
JavaScript

/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.36.0'
const migrationName = `${migrationVersion}-add-finished-dates`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration adds a finishedDates JSON column to the mediaProgresses table
* and populates it from the existing finishedAt value so that every finish event is kept
* as history (re-listens/re-reads append to this array).
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
const tableDescription = await queryInterface.describeTable('mediaProgresses')
if (!tableDescription.finishedDates) {
logger.info(`${loggerPrefix} adding column "finishedDates" to table "mediaProgresses"`)
await queryInterface.addColumn('mediaProgresses', 'finishedDates', { type: queryInterface.sequelize.Sequelize.JSON, allowNull: true })
logger.info(`${loggerPrefix} added column "finishedDates" to table "mediaProgresses"`)
} else {
logger.info(`${loggerPrefix} column "finishedDates" already exists in table "mediaProgresses"`)
}
await populateFinishedDates(queryInterface, logger)
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration removes the finishedDates column from the mediaProgresses table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
const tableDescription = await queryInterface.describeTable('mediaProgresses')
if (tableDescription.finishedDates) {
logger.info(`${loggerPrefix} removing column "finishedDates" from table "mediaProgresses"`)
await queryInterface.removeColumn('mediaProgresses', 'finishedDates')
logger.info(`${loggerPrefix} removed column "finishedDates" from table "mediaProgresses"`)
} else {
logger.info(`${loggerPrefix} column "finishedDates" does not exist in table "mediaProgresses"`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
/**
* Populates the finishedDates column with a single-element array containing the existing
* finishedAt timestamp (in milliseconds since epoch) for rows that are missing it.
* Idempotent: only touches rows where finishedDates is still null.
*
* @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @param {import('../Logger')} logger - a Logger object.
*/
async function populateFinishedDates(queryInterface, logger) {
const [rows] = await queryInterface.sequelize.query(`SELECT id, finishedAt FROM mediaProgresses WHERE finishedAt IS NOT NULL AND finishedDates IS NULL`)
logger.info(`${loggerPrefix} populating finishedDates column for ${rows.length} rows in mediaProgresses table`)
await queryInterface.sequelize.transaction(async (transaction) => {
for (const row of rows) {
const finishedAtMs = new Date(row.finishedAt).valueOf()
if (isNaN(finishedAtMs)) {
logger.error(`${loggerPrefix} invalid finishedAt value "${row.finishedAt}" for mediaProgress ${row.id}, skipping`)
continue
}
await queryInterface.sequelize.query(`UPDATE mediaProgresses SET finishedDates = :finishedDates WHERE id = :id`, {
replacements: { finishedDates: JSON.stringify([finishedAtMs]), id: row.id },
transaction
})
}
})
logger.info(`${loggerPrefix} populated finishedDates column in mediaProgresses table`)
}
module.exports = { up, down }