Phase 1 Week 2: Format detection and progress conversion engine

- Add internal/sync package with format detection
- FormatGroup types: reflowable, fixed_layout, comic_archive
- DetectFormatGroup() function based on mimetype and file extension
- MimeType mappings for common ebook formats
- Progress conversion engine with:
  - ConvertProgress() between format groups
  - Extract percentage from various progress formats
  - PageToPercentage / PercentageToPage helpers
  - CharacterToPercentage / PercentageToCharacter helpers
  - MergeProgress() with 'max progress wins' strategy
  - FormatProgressForDisplay() for UI rendering
- Add sqlc queries for format detection and progress updates
- BulkUpdateFormatGroups query for auto-format detection
- GetUniversalProgress query with all location references
- UpdateUniversalProgress query with device sync metadata
- ReadingHistory queries for session tracking
This commit is contained in:
2026-01-30 16:07:00 -05:00
parent fa4a9c35bb
commit bb3c32c59f
6 changed files with 3101 additions and 0 deletions
+15
View File
@@ -13,6 +13,8 @@ import (
type Querier interface {
// Library Folders queries
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
// Bulk update format group for all media items
BulkUpdateFormatGroups(ctx context.Context) error
CleanupExpiredRefreshTokens(ctx context.Context) error
// Backward compatibility - Ebook Notes queries (using views)
CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error)
@@ -25,6 +27,8 @@ type Querier interface {
// Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
// Create reading history entry
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
// Refresh Tokens queries
CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshTokens, error)
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
@@ -53,9 +57,13 @@ type Querier interface {
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
// Get reading history for a user and book
GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error)
GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error)
GetRefreshToken(ctx context.Context, token string) (GetRefreshTokenRow, error)
GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanSettingsRow, error)
// Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
@@ -83,11 +91,18 @@ type Querier interface {
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
// ============================================
// PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
// ============================================
// Update media item format group information
UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateScanSettings(ctx context.Context, arg UpdateScanSettingsParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
+395
View File
@@ -33,6 +33,23 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara
return i, err
}
const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec
UPDATE media_items m
SET
format_group = detect_format_group(m.mime_type, m.file_path),
format_mimetype = m.mime_type,
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
updated_at = NOW()
WHERE m.format_group IS NULL OR m.format_group = 'unknown'
`
// Bulk update format group for all media items
func (q *Queries) BulkUpdateFormatGroups(ctx context.Context) error {
_, err := q.db.Exec(ctx, BulkUpdateFormatGroups)
return err
}
const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')
`
@@ -345,6 +362,64 @@ func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingPa
return i, err
}
const CreateReadingHistory = `-- name: CreateReadingHistory :one
INSERT INTO reading_history (
user_id,
media_item_id,
device_id,
progress_percentage,
reading_session_start,
reading_session_end,
pages_read,
time_spent_seconds,
device_metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
`
type CreateReadingHistoryParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ProgressPercentage pgtype.Float8 `db:"progress_percentage" json:"progress_percentage"`
ReadingSessionStart pgtype.Timestamptz `db:"reading_session_start" json:"reading_session_start"`
ReadingSessionEnd pgtype.Timestamptz `db:"reading_session_end" json:"reading_session_end"`
PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"`
TimeSpentSeconds pgtype.Int4 `db:"time_spent_seconds" json:"time_spent_seconds"`
DeviceMetadata []byte `db:"device_metadata" json:"device_metadata"`
}
// Create reading history entry
func (q *Queries) CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error) {
row := q.db.QueryRow(ctx, CreateReadingHistory,
arg.UserID,
arg.MediaItemID,
arg.DeviceID,
arg.ProgressPercentage,
arg.ReadingSessionStart,
arg.ReadingSessionEnd,
arg.PagesRead,
arg.TimeSpentSeconds,
arg.DeviceMetadata,
)
var i ReadingHistory
err := row.Scan(
&i.ID,
&i.UserID,
&i.MediaItemID,
&i.DeviceID,
&i.ProgressPercentage,
&i.ReadingSessionStart,
&i.ReadingSessionEnd,
&i.PagesRead,
&i.TimeSpentSeconds,
&i.DeviceMetadata,
&i.CreatedAt,
)
return i, err
}
const CreateRefreshToken = `-- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES ($1, $2, $3)
@@ -1029,6 +1104,53 @@ func (q *Queries) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID)
return items, nil
}
const GetReadingHistory = `-- name: GetReadingHistory :many
SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at
FROM reading_history
WHERE user_id = $1 AND media_item_id = $2
ORDER BY created_at DESC
LIMIT $3
`
type GetReadingHistoryParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Limit int32 `db:"limit" json:"limit"`
}
// Get reading history for a user and book
func (q *Queries) GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) {
rows, err := q.db.Query(ctx, GetReadingHistory, arg.UserID, arg.MediaItemID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ReadingHistory{}
for rows.Next() {
var i ReadingHistory
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.MediaItemID,
&i.DeviceID,
&i.ProgressPercentage,
&i.ReadingSessionStart,
&i.ReadingSessionEnd,
&i.PagesRead,
&i.TimeSpentSeconds,
&i.DeviceMetadata,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetReadingProgress = `-- name: GetReadingProgress :one
SELECT id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved FROM reading_progress WHERE media_item_id = $1 AND user_id = $2
`
@@ -1121,6 +1243,117 @@ func (q *Queries) GetScanSettings(ctx context.Context, id pgtype.UUID) (GetScanS
return i, err
}
const GetUniversalProgress = `-- name: GetUniversalProgress :one
SELECT
rp.id,
rp.media_item_id,
rp.user_id,
rp.current_page,
rp.total_pages,
rp.last_read_at,
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.chapter,
rp.chapter_progress,
rp.viewport_x,
rp.viewport_y,
rp.zoom_level,
rp.scroll_position_x,
rp.scroll_position_y,
rp.panel_number,
rp.reading_mode,
rp.last_sync_device,
rp.last_sync_source,
rp.last_sync_timestamp,
rp.conflict_detected,
rp.conflict_resolved,
mi.format_group,
mi.format_mimetype,
mi.is_reflowable,
mi.has_fixed_layout,
mi.total_characters,
mi.chapter_count
FROM reading_progress rp
JOIN media_items mi ON rp.media_item_id = mi.id
WHERE rp.media_item_id = $1 AND rp.user_id = $2
`
type GetUniversalProgressParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
}
type GetUniversalProgressRow struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
LastSyncTimestamp pgtype.Timestamptz `db:"last_sync_timestamp" json:"last_sync_timestamp"`
ConflictDetected pgtype.Bool `db:"conflict_detected" json:"conflict_detected"`
ConflictResolved pgtype.Bool `db:"conflict_resolved" json:"conflict_resolved"`
FormatGroup string `db:"format_group" json:"format_group"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
}
// Get universal progress for a book
func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) {
row := q.db.QueryRow(ctx, GetUniversalProgress, arg.MediaItemID, arg.UserID)
var i GetUniversalProgressRow
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
&i.Percentage,
&i.CharacterOffset,
&i.Epubcfi,
&i.Chapter,
&i.ChapterProgress,
&i.ViewportX,
&i.ViewportY,
&i.ZoomLevel,
&i.ScrollPositionX,
&i.ScrollPositionY,
&i.PanelNumber,
&i.ReadingMode,
&i.LastSyncDevice,
&i.LastSyncSource,
&i.LastSyncTimestamp,
&i.ConflictDetected,
&i.ConflictResolved,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
)
return i, err
}
const GetUser = `-- name: GetUser :one
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE id = $1
`
@@ -2587,6 +2820,47 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
return i, err
}
const UpdateMediaItemFormatGroup = `-- name: UpdateMediaItemFormatGroup :exec
UPDATE media_items
SET
format_group = $2,
format_mimetype = $3,
is_reflowable = $4,
has_fixed_layout = $5,
total_characters = $6,
chapter_count = $7,
updated_at = NOW()
WHERE id = $1
`
type UpdateMediaItemFormatGroupParams struct {
ID pgtype.UUID `db:"id" json:"id"`
FormatGroup string `db:"format_group" json:"format_group"`
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
}
// ============================================
// PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
// ============================================
// Update media item format group information
func (q *Queries) UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error {
_, err := q.db.Exec(ctx, UpdateMediaItemFormatGroup,
arg.ID,
arg.FormatGroup,
arg.FormatMimetype,
arg.IsReflowable,
arg.HasFixedLayout,
arg.TotalCharacters,
arg.ChapterCount,
)
return err
}
const UpdateMediaNote = `-- name: UpdateMediaNote :one
UPDATE media_notes SET
content = $2,
@@ -2735,6 +3009,127 @@ func (q *Queries) UpdateScanSettings(ctx context.Context, arg UpdateScanSettings
return err
}
const UpdateUniversalProgress = `-- name: UpdateUniversalProgress :one
INSERT INTO reading_progress (
media_item_id,
user_id,
percentage,
character_offset,
epubcfi,
chapter,
chapter_progress,
viewport_x,
viewport_y,
zoom_level,
scroll_position_x,
scroll_position_y,
panel_number,
reading_mode,
last_sync_device,
last_sync_source,
last_sync_timestamp,
current_page,
total_pages,
last_read_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi,
chapter = EXCLUDED.chapter,
chapter_progress = EXCLUDED.chapter_progress,
viewport_x = EXCLUDED.viewport_x,
viewport_y = EXCLUDED.viewport_y,
zoom_level = EXCLUDED.zoom_level,
scroll_position_x = EXCLUDED.scroll_position_x,
scroll_position_y = EXCLUDED.scroll_position_y,
panel_number = EXCLUDED.panel_number,
reading_mode = EXCLUDED.reading_mode,
last_sync_device = EXCLUDED.last_sync_device,
last_sync_source = EXCLUDED.last_sync_source,
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING id, media_item_id, user_id, current_page, total_pages, last_read_at, percentage, character_offset, epubcfi, chapter, chapter_progress, viewport_x, viewport_y, zoom_level, scroll_position_x, scroll_position_y, panel_number, reading_mode, last_sync_device, last_sync_source, last_sync_timestamp, conflict_detected, conflict_resolved
`
type UpdateUniversalProgressParams struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
ViewportY pgtype.Float8 `db:"viewport_y" json:"viewport_y"`
ZoomLevel pgtype.Float8 `db:"zoom_level" json:"zoom_level"`
ScrollPositionX pgtype.Float8 `db:"scroll_position_x" json:"scroll_position_x"`
ScrollPositionY pgtype.Float8 `db:"scroll_position_y" json:"scroll_position_y"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
ReadingMode pgtype.Text `db:"reading_mode" json:"reading_mode"`
LastSyncDevice pgtype.Text `db:"last_sync_device" json:"last_sync_device"`
LastSyncSource pgtype.Text `db:"last_sync_source" json:"last_sync_source"`
CurrentPage pgtype.Int4 `db:"current_page" json:"current_page"`
TotalPages pgtype.Int4 `db:"total_pages" json:"total_pages"`
}
// Update universal progress
func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) {
row := q.db.QueryRow(ctx, UpdateUniversalProgress,
arg.MediaItemID,
arg.UserID,
arg.Percentage,
arg.CharacterOffset,
arg.Epubcfi,
arg.Chapter,
arg.ChapterProgress,
arg.ViewportX,
arg.ViewportY,
arg.ZoomLevel,
arg.ScrollPositionX,
arg.ScrollPositionY,
arg.PanelNumber,
arg.ReadingMode,
arg.LastSyncDevice,
arg.LastSyncSource,
arg.CurrentPage,
arg.TotalPages,
)
var i ReadingProgress
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.UserID,
&i.CurrentPage,
&i.TotalPages,
&i.LastReadAt,
&i.Percentage,
&i.CharacterOffset,
&i.Epubcfi,
&i.Chapter,
&i.ChapterProgress,
&i.ViewportX,
&i.ViewportY,
&i.ZoomLevel,
&i.ScrollPositionX,
&i.ScrollPositionY,
&i.PanelNumber,
&i.ReadingMode,
&i.LastSyncDevice,
&i.LastSyncSource,
&i.LastSyncTimestamp,
&i.ConflictDetected,
&i.ConflictResolved,
)
return i, err
}
const UpdateUserProfile = `-- name: UpdateUserProfile :exec
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1
`
+137
View File
@@ -484,4 +484,141 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at I
-- name: CleanupExpiredRefreshTokens :exec
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
-- ============================================
-- PHASE 1: FORMAT DETECTION & PROGRESS (Week 2)
-- ============================================
-- Update media item format group information
-- name: UpdateMediaItemFormatGroup :exec
UPDATE media_items
SET
format_group = $2,
format_mimetype = $3,
is_reflowable = $4,
has_fixed_layout = $5,
total_characters = $6,
chapter_count = $7,
updated_at = NOW()
WHERE id = $1;
-- Bulk update format group for all media items
-- name: BulkUpdateFormatGroups :exec
UPDATE media_items m
SET
format_group = detect_format_group(m.mime_type, m.file_path),
format_mimetype = m.mime_type,
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
updated_at = NOW()
WHERE m.format_group IS NULL OR m.format_group = 'unknown';
-- Get universal progress for a book
-- name: GetUniversalProgress :one
SELECT
rp.id,
rp.media_item_id,
rp.user_id,
rp.current_page,
rp.total_pages,
rp.last_read_at,
rp.percentage,
rp.character_offset,
rp.epubcfi,
rp.chapter,
rp.chapter_progress,
rp.viewport_x,
rp.viewport_y,
rp.zoom_level,
rp.scroll_position_x,
rp.scroll_position_y,
rp.panel_number,
rp.reading_mode,
rp.last_sync_device,
rp.last_sync_source,
rp.last_sync_timestamp,
rp.conflict_detected,
rp.conflict_resolved,
mi.format_group,
mi.format_mimetype,
mi.is_reflowable,
mi.has_fixed_layout,
mi.total_characters,
mi.chapter_count
FROM reading_progress rp
JOIN media_items mi ON rp.media_item_id = mi.id
WHERE rp.media_item_id = $1 AND rp.user_id = $2;
-- Update universal progress
-- name: UpdateUniversalProgress :one
INSERT INTO reading_progress (
media_item_id,
user_id,
percentage,
character_offset,
epubcfi,
chapter,
chapter_progress,
viewport_x,
viewport_y,
zoom_level,
scroll_position_x,
scroll_position_y,
panel_number,
reading_mode,
last_sync_device,
last_sync_source,
last_sync_timestamp,
current_page,
total_pages,
last_read_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi,
chapter = EXCLUDED.chapter,
chapter_progress = EXCLUDED.chapter_progress,
viewport_x = EXCLUDED.viewport_x,
viewport_y = EXCLUDED.viewport_y,
zoom_level = EXCLUDED.zoom_level,
scroll_position_x = EXCLUDED.scroll_position_x,
scroll_position_y = EXCLUDED.scroll_position_y,
panel_number = EXCLUDED.panel_number,
reading_mode = EXCLUDED.reading_mode,
last_sync_device = EXCLUDED.last_sync_device,
last_sync_source = EXCLUDED.last_sync_source,
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
current_page = EXCLUDED.current_page,
total_pages = EXCLUDED.total_pages,
last_read_at = NOW()
RETURNING *;
-- Get reading history for a user and book
-- name: GetReadingHistory :many
SELECT *
FROM reading_history
WHERE user_id = $1 AND media_item_id = $2
ORDER BY created_at DESC
LIMIT $3;
-- Create reading history entry
-- name: CreateReadingHistory :one
INSERT INTO reading_history (
user_id,
media_item_id,
device_id,
progress_percentage,
reading_session_start,
reading_session_end,
pages_read,
time_spent_seconds,
device_metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *;
-- Media Items Admin Operations