diff --git a/cmd/server/main.go b/cmd/server/main.go index 43413559..2a15fc98 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -179,10 +179,12 @@ func main() { matchingHandler := handlers.NewMatchingHandler(queries, connManager) jobsHandler := handlers.NewJobsHandler(queries, worker) - // Podcast subsystem: fetcher service + sequential download worker + - // poller. One instance trio per server (per-feed locks dedupe). + // Podcast subsystem: fetcher service + concurrent download worker + // (§0 item 4: pool, fair share, admin tunables) + poller. One + // instance trio per server (per-feed locks dedupe). podcastService := services.NewPodcastService(queries) podcastWorker := services.NewPodcastDownloadWorker(queries, podcastService) + podcastWorker.SetSettings(registry) podcastHandler := handlers.NewPodcastHandler(queries, podcastService, podcastWorker) e := echo.New() diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 4531db8b..9afbad82 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -761,6 +761,126 @@ func TestPodcastReverseOrder(t *testing.T) { "position 3 first — a messy-date feed reorders by position, not dates") } +// ---- §0 item 4: concurrent pool — tickets, exclusivity, drain ---- + +func TestPodcastDownloadPool(t *testing.T) { + setup := setupTestServer(t) + if _, err := exec.LookPath("ffmpeg"); err != nil { + if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil { + t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio") + } + } + + lib, _ := createPodcastsLibrary(t, setup, "test Pool Podcasts") + + mp3Path := filepath.Join(t.TempDir(), "tone.mp3") + gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi", + "-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path) + out, err := gen.CombinedOutput() + require.NoError(t, err, "ffmpeg generate: %s", string(out)) + audio, err := os.ReadFile(mp3Path) + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write(audio) + }) + mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(podcastFixture("Pool Feed", map[string]string{ + "pl-1": "Pool One", "pl-2": "Pool Two", "pl-3": "Pool Three", + }))) + }) + srv := swapFeedServer(t, mux.ServeHTTP) + + libID := uuid.UUID(lib.ID.Bytes).String() + resp, body := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds", + fmt.Sprintf(`{"library_id":%q,"url":%q}`, libID, srv.URL+"/feed.xml")) + require.Equal(t, http.StatusCreated, resp, string(body)) + + adminCtx := adminSeedCtx(t, setup) + feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx) + require.NoError(t, err) + var feedRow database.PodcastFeeds + for _, f := range feeds { + if f.Title == "Pool Feed" { + feedRow = f + } + } + require.NotZero(t, feedRow.ID, "feed must exist after add") + eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + require.NoError(t, err) + require.Len(t, eps, 3) + + // Point enclosures at the test server (admin-declared write). + adminScope := database.NewScopedDB(setup.DBPool) + _, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)), + `UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`, + feedRow.ID, srv.URL+"/audio/ep.mp3") + require.NoError(t, err) + + worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB)) + actx, err := worker.AdminCtx(context.Background()) + require.NoError(t, err) + + // Queue in a KNOWN order: tickets (download_queue_pos SEQUENCE) make + // the enqueue loop's ordering the claim order — the §0 item 5 + // foundation ("getting the last chapter first doesn't help me"). + ordered := make([]database.MediaItems, 0, len(eps)) + for _, wantPos := range []int32{1, 2, 3} { + for _, ep := range eps { + if ep.FeedPosition.Int32 == wantPos { + ordered = append(ordered, ep) + } + } + } + for _, ep := range ordered { + _, err := setup.DB.SetPodcastEpisodeDownloadQueued(actx, ep.ID) + require.NoError(t, err) + } + + queued, err := setup.DB.ListQueuedPodcastEpisodes(actx) + require.NoError(t, err) + require.Len(t, queued, 3) + for i, ep := range ordered { + assert.Equal(t, ep.ID, queued[i].ID, + "PIN: ticket order = enqueue order (claim claims in enqueue order)") + } + + t.Run("claim_is_exclusive", func(t *testing.T) { + first, err := setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID) + require.NoError(t, err) + assert.Equal(t, "downloading", first.DownloadState) + // A second claimant racing the same row gets NOTHING — the + // single-statement guard (queued → downloading) is the + // SKIP-LOCKED-equivalent guarantee. + _, err = setup.DB.ClaimQueuedPodcastEpisode(actx, queued[0].ID) + assert.Error(t, err, "second claim of a downloading row must not match") + + // Hand it back for the drain below (enqueue only takes + // pending|failed — a stuck 'downloading' needs the manual reset, + // exactly what the crash-recovery path does). + require.NoError(t, setup.DB.SetPodcastEpisodePending(actx, queued[0].ID)) + _, err = setup.DB.SetPodcastEpisodeDownloadQueued(actx, queued[0].ID) + require.NoError(t, err) + }) + + worker.DrainNow(context.Background()) + require.Eventually(t, func() bool { + rowset, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID) + if err != nil { + return false + } + for _, row := range rowset { + if row.DownloadState != "downloaded" { + return false + } + } + return len(rowset) == 3 + }, 30*time.Second, 250*time.Millisecond, "all 3 episodes must download through the pool") +} + // ---- issue #8: page search hits EPISODE titles/descriptions too ---- func TestPodcastPageSearchEpisodes(t *testing.T) { diff --git a/database/schema/schema.sql b/database/schema/schema.sql index ab80855d..b22f7a40 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -102,7 +102,9 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty ('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'), ('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance'), -- podcasts -('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts') +('podcast_poll_interval_seconds', '3600', 'How often to poll podcast feeds for new episodes (seconds; complete feeds back off)', 'int', '300', '604800', FALSE, 'podcasts'), +('podcast_download_concurrency', '4', 'Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)', 'int', '1', '16', FALSE, 'podcasts'), +('podcast_download_speed_mbps', '0', 'Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)', 'float', '0', '10000', FALSE, 'podcasts') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table @@ -1989,7 +1991,16 @@ ALTER TABLE media_items -- list renders this inline (D8/D19) — never Processing Issues. ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending', ADD COLUMN IF NOT EXISTS download_error TEXT, - ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0; + ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0, + -- Monotonic queue ticket (Postgres SEQUENCE nextval at enqueue + -- time): the download pool claims in ticket order, so the enqueue + -- loop's ordering IS the download order (effective listening order + -- — §0 items 4/5). NULL for rows never queued. + ADD COLUMN IF NOT EXISTS download_queue_pos BIGINT; + +-- Queue ticket source: atomic across concurrent enqueues (per-feed +-- locks serialize within a feed; the sequence covers across feeds). +CREATE SEQUENCE IF NOT EXISTS podcast_queue_seq; -- Identity + ordering + worker-worklist indexes (all partial: scan -- rows never touch these columns). @@ -1999,6 +2010,8 @@ CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded'; +CREATE INDEX IF NOT EXISTS idx_media_items_queue + ON media_items(download_queue_pos) WHERE source = 'podcast_episode' AND download_state = 'queued'; -- ============================================================ diff --git a/internal/database/db.go b/internal/database/db.go index 486aa36f..371b20c8 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.31.0 package database diff --git a/internal/database/models.go b/internal/database/models.go index a4ed38b3..c1a63b4b 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.31.0 package database @@ -355,6 +355,7 @@ type MediaItems struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` } type MediaNotes struct { diff --git a/internal/database/querier.go b/internal/database/querier.go index 3eb75941..25a6e331 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.31.0 package database @@ -32,6 +32,11 @@ type Querier interface { BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error) + // Atomic claim (§0 item 4): converts queued -> downloading in ONE + // statement. Two claimants racing on the same row: exactly one UPDATE + // matches download_state='queued'; the loser gets no rows (ErrNoRows). + // This is the SKIP-LOCKED guarantee without a second round-trip. + ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error) // Cleanup expired OPDS tokens CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error @@ -446,8 +451,6 @@ type Querier interface { // GetMediaScanState reads one item's manifest (per-item rescan paths // and the manifest integration tests). GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error) - // Sequential download worker's claim source (single worker, day one). - GetNextQueuedEpisode(ctx context.Context) (MediaItems, error) GetNextRetryTime(ctx context.Context) (interface{}, error) GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error) // Get OPDS token @@ -656,6 +659,11 @@ type Querier interface { // Item-side projections COALESCE to keep them non-nullable strings; // the display file path falls back to the issue's own for row-less rows. ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) + // Fair-share candidate scan (§0 item 4): the download pool claims in + // TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the + // enqueue loop's ordering IS the download order, item 5). The worker + // groups these per feed and prefers the least-recently-served feed. + ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) // Download All + policy union worklist. @@ -668,6 +676,9 @@ type Querier interface { ListUsers(ctx context.Context) ([]ListUsersRow, error) // First consecutive scan that cannot find the file on disk. MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error + // SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair + // GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under + // concurrency; ClaimQueuedPodcastEpisode does both atomically. // SUCCESS-only write (invariant 7): the file is on disk and probed. // ffprobe facts (real duration via audio_metadata, precise size/mime) // reconcile the feed's declared values. @@ -753,10 +764,11 @@ type Querier interface { SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error) // Library Visibility queries SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) - // Worker claim: pending|failed -> queued. Attempts incremented at - // claim time so a crashed worker still counts the try. + // Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET + // (§0 items 4/5) so claim order = enqueue order (the effective + // listening order the enqueue loop applied). Attempts increment at + // enqueue so a crashed worker still counts the try. SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error) - SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error // Manual retry (same endpoint as download): reset to pending so the // worker re-claims it; the reason text clears with the fresh attempt. SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 77fde77b..45c33b97 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.31.0 // source: queries.sql package database @@ -228,6 +228,117 @@ func (q *Queries) CheckForProgressConflicts(ctx context.Context, arg CheckForPro return conflict_count, err } +const ClaimQueuedPodcastEpisode = `-- name: ClaimQueuedPodcastEpisode :one +UPDATE media_items +SET download_state = 'downloading', updated_at = NOW() +WHERE id = $1 + AND source = 'podcast_episode' + AND download_state = 'queued' +RETURNING media_items.id, media_items.library_id, media_items.title, media_items.subtitle, media_items.author, media_items.isbn, media_items.description, media_items.file_path, media_items.file_size, media_items.mime_type, media_items.cover_image_path, media_items.series, media_items.series_number, media_items.tags, media_items.narrators, media_items.authors, media_items.authors_search, media_items.publishers, media_items.publishers_search, media_items.abridged, media_items.explicit, media_items.asin, media_items.date_published, media_items.publisher, media_items.contributors, media_items.language, media_items.edition, media_items.page_count, media_items.genre, media_items.copyright_year, media_items.goodreads_id, media_items.openlibrary_id, media_items.google_books_id, media_items.added_by_admin_id, media_items.created_at, media_items.imported_at, media_items.updated_at, media_items.format_group, media_items.format_mimetype, media_items.is_reflowable, media_items.has_fixed_layout, media_items.total_characters, media_items.chapter_count, media_items.entitlement_id, media_items.revision_number, media_items.kobo_content_id, media_items.kobo_metadata, media_items.manga_type, media_items.reading_direction, media_items.series_count, media_items.volume, media_items.imprint, media_items.age_rating, media_items.web_url, media_items.story_arc, media_items.is_black_and_white, media_items.metadata_notes, media_items.community_rating, media_items.alternate_info, media_items.scan_information, media_items.summary, media_items.metadata_overrides, media_items.missing_scan_count, media_items.archived_at, media_items.chapter_metadata, media_items.library_type_name, media_items.audio_metadata, media_items.tags_search, media_items.contributors_search, media_items.narrators_search, media_items.file_sha256, media_items.opf_identifier, media_items.opf_uuid, media_items.hash_confidence, media_items.source, media_items.source_feed_id, media_items.episode_guid, media_items.feed_position, media_items.season_number, media_items.episode_number, media_items.enclosure_url, media_items.declared_duration_seconds, media_items.transcript_url, media_items.chapters_url, media_items.hosts, media_items.guests, media_items.download_state, media_items.download_error, media_items.download_attempts, media_items.download_queue_pos +` + +// Atomic claim (§0 item 4): converts queued -> downloading in ONE +// statement. Two claimants racing on the same row: exactly one UPDATE +// matches download_state='queued'; the loser gets no rows (ErrNoRows). +// This is the SKIP-LOCKED guarantee without a second round-trip. +func (q *Queries) ClaimQueuedPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error) { + row := q.db.QueryRow(ctx, ClaimQueuedPodcastEpisode, id) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Subtitle, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Narrators, + &i.Authors, + &i.AuthorsSearch, + &i.Publishers, + &i.PublishersSearch, + &i.Abridged, + &i.Explicit, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.AudioMetadata, + &i.TagsSearch, + &i.ContributorsSearch, + &i.NarratorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + &i.Source, + &i.SourceFeedID, + &i.EpisodeGuid, + &i.FeedPosition, + &i.SeasonNumber, + &i.EpisodeNumber, + &i.EnclosureUrl, + &i.DeclaredDurationSeconds, + &i.TranscriptUrl, + &i.ChaptersUrl, + &i.Hosts, + &i.Guests, + &i.DownloadState, + &i.DownloadError, + &i.DownloadAttempts, + &i.DownloadQueuePos, + ) + return i, err +} + const CleanupExpiredOpdsTokens = `-- name: CleanupExpiredOpdsTokens :exec DELETE FROM opds_tokens WHERE expires_at < NOW() ` @@ -1048,7 +1159,7 @@ const CreateMediaItem = `-- name: CreateMediaItem :one INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52) ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type CreateMediaItemParams struct { @@ -1256,6 +1367,7 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -1313,7 +1425,7 @@ const CreateMediaItemIfNew = `-- name: CreateMediaItemIfNew :one INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52) ON CONFLICT (library_id, file_path) DO NOTHING -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type CreateMediaItemIfNewParams struct { @@ -1523,6 +1635,7 @@ func (q *Queries) CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemI &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -4148,7 +4261,7 @@ func (q *Queries) GetBookReadingSessions(ctx context.Context, arg GetBookReading } const GetBooksByTag = `-- name: GetBooksByTag :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE library_id = $1 AND tags @> ARRAY[$2::text] ORDER BY title ASC ` @@ -4257,6 +4370,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([ &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -4429,7 +4543,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU } const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, rp.percentage AS viewer_percentage FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid WHERE ci.collection_id = $2 @@ -4569,6 +4683,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol &i.MediaItems.DownloadState, &i.MediaItems.DownloadError, &i.MediaItems.DownloadAttempts, + &i.MediaItems.DownloadQueuePos, &i.ViewerPercentage, ); err != nil { return nil, err @@ -4582,7 +4697,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol } const GetCollectionUpNext = `-- name: GetCollectionUpNext :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, rp.percentage AS viewer_percentage FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, rp.percentage AS viewer_percentage FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid WHERE ci.collection_id = $2 @@ -4715,6 +4830,7 @@ func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNe &i.MediaItems.DownloadState, &i.MediaItems.DownloadError, &i.MediaItems.DownloadAttempts, + &i.MediaItems.DownloadQueuePos, &i.ViewerPercentage, ); err != nil { return nil, err @@ -4864,7 +4980,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype. } const GetContinueReadingItems = `-- name: GetContinueReadingItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi INNER JOIN ( SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at FROM reading_progress @@ -4987,6 +5103,7 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -5012,7 +5129,7 @@ WITH user_series_progress AS ( GROUP BY mi.series ), next_books AS ( - SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, + SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, usp.last_read_at FROM media_items mi JOIN user_series_progress usp ON mi.series = usp.series @@ -5022,7 +5139,7 @@ next_books AS ( AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL) ORDER BY mi.series, mi.series_number ASC NULLS LAST ) -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, last_read_at FROM next_books +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos, last_read_at FROM next_books ORDER BY last_read_at DESC NULLS LAST LIMIT $1 ` @@ -5123,6 +5240,7 @@ type GetContinueSeriesItemsRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LastReadAt interface{} `db:"last_read_at" json:"last_read_at"` } @@ -5225,6 +5343,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LastReadAt, ); err != nil { return nil, err @@ -6539,7 +6658,7 @@ func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) } const GetLibraryItems = `-- name: GetLibraryItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) AND mi.archived_at IS NULL AND mi.missing_scan_count = 0 @@ -6646,6 +6765,7 @@ func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([ &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -7357,7 +7477,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights } const GetMediaItem = `-- name: GetMediaItem :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE id = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE id = $1 ` func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) { @@ -7453,12 +7573,13 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_path = $1 AND library_id = $2 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1 AND library_id = $2 ` type GetMediaItemByFilePathParams struct { @@ -7559,12 +7680,13 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_path = $1 LIMIT 1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1 LIMIT 1 ` func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) { @@ -7660,13 +7782,14 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_path = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_path = $1 ` // ============================================ @@ -7765,12 +7888,13 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE kobo_content_id = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE kobo_content_id = $1 ` func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) { @@ -7866,12 +7990,13 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByLibraryAndPath = `-- name: GetMediaItemByLibraryAndPath :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE library_id = $1 AND file_path = $2 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE library_id = $1 AND file_path = $2 ` type GetMediaItemByLibraryAndPathParams struct { @@ -7972,12 +8097,13 @@ func (q *Queries) GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMedia &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE opf_identifier = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE opf_identifier = $1 ` // Get media item by OPF identifier @@ -8074,12 +8200,13 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE opf_uuid = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE opf_uuid = $1 ` // Get media item by OPF UUID @@ -8176,12 +8303,13 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_sha256 = $1 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1 ` // Get media item by SHA-256 hash @@ -8278,12 +8406,13 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_sha256 = $1 AND library_id = $2 +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ` type GetMediaItemBySHA256AndLibraryParams struct { @@ -8385,6 +8514,7 @@ func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMed &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -8505,7 +8635,7 @@ func (q *Queries) GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtyp } const GetMediaItemsBySHA256AndSameLibraryType = `-- name: GetMediaItemsBySHA256AndSameLibraryType :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, lt.name AS library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, lt.name AS library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -8613,6 +8743,7 @@ type GetMediaItemsBySHA256AndSameLibraryTypeRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -8720,6 +8851,7 @@ func (q *Queries) GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, a &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryTypeName_2, ); err != nil { return nil, err @@ -8962,111 +9094,6 @@ func (q *Queries) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID return i, err } -const GetNextQueuedEpisode = `-- name: GetNextQueuedEpisode :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items -WHERE source = 'podcast_episode' AND download_state = 'queued' -ORDER BY updated_at ASC -LIMIT 1 -` - -// Sequential download worker's claim source (single worker, day one). -func (q *Queries) GetNextQueuedEpisode(ctx context.Context) (MediaItems, error) { - row := q.db.QueryRow(ctx, GetNextQueuedEpisode) - var i MediaItems - err := row.Scan( - &i.ID, - &i.LibraryID, - &i.Title, - &i.Subtitle, - &i.Author, - &i.Isbn, - &i.Description, - &i.FilePath, - &i.FileSize, - &i.MimeType, - &i.CoverImagePath, - &i.Series, - &i.SeriesNumber, - &i.Tags, - &i.Narrators, - &i.Authors, - &i.AuthorsSearch, - &i.Publishers, - &i.PublishersSearch, - &i.Abridged, - &i.Explicit, - &i.Asin, - &i.DatePublished, - &i.Publisher, - &i.Contributors, - &i.Language, - &i.Edition, - &i.PageCount, - &i.Genre, - &i.CopyrightYear, - &i.GoodreadsID, - &i.OpenlibraryID, - &i.GoogleBooksID, - &i.AddedByAdminID, - &i.CreatedAt, - &i.ImportedAt, - &i.UpdatedAt, - &i.FormatGroup, - &i.FormatMimetype, - &i.IsReflowable, - &i.HasFixedLayout, - &i.TotalCharacters, - &i.ChapterCount, - &i.EntitlementID, - &i.RevisionNumber, - &i.KoboContentID, - &i.KoboMetadata, - &i.MangaType, - &i.ReadingDirection, - &i.SeriesCount, - &i.Volume, - &i.Imprint, - &i.AgeRating, - &i.WebUrl, - &i.StoryArc, - &i.IsBlackAndWhite, - &i.MetadataNotes, - &i.CommunityRating, - &i.AlternateInfo, - &i.ScanInformation, - &i.Summary, - &i.MetadataOverrides, - &i.MissingScanCount, - &i.ArchivedAt, - &i.ChapterMetadata, - &i.LibraryTypeName, - &i.AudioMetadata, - &i.TagsSearch, - &i.ContributorsSearch, - &i.NarratorsSearch, - &i.FileSha256, - &i.OpfIdentifier, - &i.OpfUuid, - &i.HashConfidence, - &i.Source, - &i.SourceFeedID, - &i.EpisodeGuid, - &i.FeedPosition, - &i.SeasonNumber, - &i.EpisodeNumber, - &i.EnclosureUrl, - &i.DeclaredDurationSeconds, - &i.TranscriptUrl, - &i.ChaptersUrl, - &i.Hosts, - &i.Guests, - &i.DownloadState, - &i.DownloadError, - &i.DownloadAttempts, - ) - return i, err -} - const GetNextRetryTime = `-- name: GetNextRetryTime :one SELECT CASE @@ -9087,7 +9114,7 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) { } const GetNotStartedItems = `-- name: GetNotStartedItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9) AND mi.archived_at IS NULL @@ -9207,6 +9234,7 @@ func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItems &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -9367,7 +9395,7 @@ func (q *Queries) GetPodcastContinueForUser(ctx context.Context, userID pgtype.U } const GetPodcastEpisode = `-- name: GetPodcastEpisode :one -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE id = $1 AND source = 'podcast_episode' ` @@ -9464,6 +9492,7 @@ func (q *Queries) GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaI &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -9919,7 +9948,7 @@ func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams } const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9) AND mi.archived_at IS NULL @@ -10032,6 +10061,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -10044,7 +10074,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde } const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi INNER JOIN ( SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at FROM reading_progress @@ -10165,6 +10195,7 @@ func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadI &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -10339,7 +10370,7 @@ func (q *Queries) GetScanStateByLibrary(ctx context.Context, libraryID pgtype.UU } const GetSeriesBooks = `-- name: GetSeriesBooks :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE series = $1 AND library_id = $2 AND archived_at IS NULL @@ -10454,6 +10485,7 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -12191,7 +12223,7 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp } const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts FROM media_items mi +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos FROM media_items mi WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[])) AND mi.archived_at IS NULL @@ -12308,6 +12340,7 @@ func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibl &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -12868,7 +12901,7 @@ func (q *Queries) ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ( } const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name, +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name, twin.id AS match_id, COALESCE(twin.title, '') AS match_title, twin_library.name AS match_library_name FROM media_items mi @@ -12984,6 +13017,7 @@ type ListHiddenMediaItemsRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` MatchID pgtype.UUID `db:"match_id" json:"match_id"` @@ -13096,6 +13130,7 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, &i.MatchID, @@ -13230,7 +13265,7 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) } const ListMediaItems = `-- name: ListMediaItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -13337,6 +13372,7 @@ type ListMediaItemsRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -13443,6 +13479,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, ); err != nil { @@ -13457,7 +13494,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) } const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -13557,6 +13594,7 @@ type ListMediaItemsByLibraryRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -13660,6 +13698,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype. &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, ); err != nil { @@ -13674,7 +13713,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype. } const ListMediaItemsByLibraryIncludingArchived = `-- name: ListMediaItemsByLibraryIncludingArchived :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -13772,6 +13811,7 @@ type ListMediaItemsByLibraryIncludingArchivedRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -13875,6 +13915,7 @@ func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context, &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, ); err != nil { @@ -13889,7 +13930,7 @@ func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context, } const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path ` type ListMediaItemsBySHA256AndLibraryParams struct { @@ -13997,6 +14038,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -14009,7 +14051,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List } const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at ` // List media items that have no stored SHA-256 (imported before hashing existed) @@ -14112,6 +14154,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -14124,7 +14167,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, } const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -14298,6 +14341,7 @@ type ListMediaItemsSortedRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -14406,6 +14450,7 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, ); err != nil { @@ -14610,7 +14655,7 @@ func (q *Queries) ListPendingSyncQueueItems(ctx context.Context, arg ListPending } const ListPodcastEpisodes = `-- name: ListPodcastEpisodes :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE source_feed_id = $1 AND source = 'podcast_episode' ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC ` @@ -14717,6 +14762,7 @@ func (q *Queries) ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.U &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -14860,6 +14906,41 @@ func (q *Queries) ListProcessingIssuesByLibrary(ctx context.Context, libraryID p return items, nil } +const ListQueuedPodcastEpisodes = `-- name: ListQueuedPodcastEpisodes :many +SELECT id, source_feed_id FROM media_items +WHERE source = 'podcast_episode' AND download_state = 'queued' +ORDER BY download_queue_pos ASC NULLS LAST +` + +type ListQueuedPodcastEpisodesRow struct { + ID pgtype.UUID `db:"id" json:"id"` + SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"` +} + +// Fair-share candidate scan (§0 item 4): the download pool claims in +// TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the +// enqueue loop's ordering IS the download order, item 5). The worker +// groups these per feed and prefers the least-recently-served feed. +func (q *Queries) ListQueuedPodcastEpisodes(ctx context.Context) ([]ListQueuedPodcastEpisodesRow, error) { + rows, err := q.db.Query(ctx, ListQueuedPodcastEpisodes) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListQueuedPodcastEpisodesRow{} + for rows.Next() { + var i ListQueuedPodcastEpisodesRow + if err := rows.Scan(&i.ID, &i.SourceFeedID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListSyncConflictsByMediaItem = `-- name: ListSyncConflictsByMediaItem :many SELECT id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at FROM sync_conflicts WHERE media_item_id = $1 AND user_id = $2 @@ -14959,7 +15040,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI } const ListUndownloadedEpisodes = `-- name: ListUndownloadedEpisodes :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE source_feed_id = $1 AND source = 'podcast_episode' AND download_state <> 'downloaded' @@ -15066,6 +15147,7 @@ func (q *Queries) ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgt &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -15289,6 +15371,7 @@ func (q *Queries) MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) erro } const MarkPodcastEpisodeDownloaded = `-- name: MarkPodcastEpisodeDownloaded :one + UPDATE media_items SET download_state = 'downloaded', download_error = NULL, @@ -15300,7 +15383,7 @@ SET download_state = 'downloaded', chapter_count = $7, updated_at = NOW() WHERE id = $1 AND source = 'podcast_episode' -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type MarkPodcastEpisodeDownloadedParams struct { @@ -15313,6 +15396,9 @@ type MarkPodcastEpisodeDownloadedParams struct { ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"` } +// SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair +// GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under +// concurrency; ClaimQueuedPodcastEpisode does both atomically. // SUCCESS-only write (invariant 7): the file is on disk and probed. // ffprobe facts (real duration via audio_metadata, precise size/mime) // reconcile the feed's declared values. @@ -15417,6 +15503,7 @@ func (q *Queries) MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodc &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -16390,7 +16477,7 @@ func (q *Queries) SearchLanguageValues(ctx context.Context, arg SearchLanguageVa } const SearchMediaItems = `-- name: SearchMediaItems :many -SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, l.name as library_name, lt.name as library_type_name +SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, mi.source, mi.source_feed_id, mi.episode_guid, mi.feed_position, mi.season_number, mi.episode_number, mi.enclosure_url, mi.declared_duration_seconds, mi.transcript_url, mi.chapters_url, mi.hosts, mi.guests, mi.download_state, mi.download_error, mi.download_attempts, mi.download_queue_pos, l.name as library_name, lt.name as library_type_name FROM media_items mi JOIN libraries l ON mi.library_id = l.id JOIN library_types lt ON l.library_type_id = lt.id @@ -16517,6 +16604,7 @@ type SearchMediaItemsRow struct { DownloadState string `db:"download_state" json:"download_state"` DownloadError pgtype.Text `db:"download_error" json:"download_error"` DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"` + DownloadQueuePos pgtype.Int8 `db:"download_queue_pos" json:"download_queue_pos"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"` } @@ -16627,6 +16715,7 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, &i.LibraryName, &i.LibraryTypeName_2, ); err != nil { @@ -17083,7 +17172,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt } const SearchMyPodcastEpisodes = `-- name: SearchMyPodcastEpisodes :many -SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items +SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos FROM media_items WHERE source = 'podcast_episode' AND ($1::text = '' OR title ILIKE '%' || $1::text || '%' OR description ILIKE '%' || $1::text || '%') ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST @@ -17193,6 +17282,7 @@ func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, searchQuery strin &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ); err != nil { return nil, err } @@ -17556,6 +17646,7 @@ func (q *Queries) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibi const SetPodcastEpisodeDownloadQueued = `-- name: SetPodcastEpisodeDownloadQueued :execrows UPDATE media_items SET download_state = 'queued', + download_queue_pos = nextval('podcast_queue_seq'), download_error = NULL, download_attempts = download_attempts + 1, updated_at = NOW() @@ -17564,8 +17655,10 @@ WHERE id = $1 AND download_state IN ('pending', 'failed') ` -// Worker claim: pending|failed -> queued. Attempts incremented at -// claim time so a crashed worker still counts the try. +// Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET +// (§0 items 4/5) so claim order = enqueue order (the effective +// listening order the enqueue loop applied). Attempts increment at +// enqueue so a crashed worker still counts the try. func (q *Queries) SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error) { result, err := q.db.Exec(ctx, SetPodcastEpisodeDownloadQueued, id) if err != nil { @@ -17574,17 +17667,6 @@ func (q *Queries) SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype return result.RowsAffected(), nil } -const SetPodcastEpisodeDownloading = `-- name: SetPodcastEpisodeDownloading :exec -UPDATE media_items -SET download_state = 'downloading', updated_at = NOW() -WHERE id = $1 AND source = 'podcast_episode' -` - -func (q *Queries) SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, SetPodcastEpisodeDownloading, id) - return err -} - const SetPodcastEpisodePending = `-- name: SetPodcastEpisodePending :exec UPDATE media_items SET download_state = 'pending', @@ -18587,7 +18669,7 @@ UPDATE media_items SET explicit = $46, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type UpdateMediaItemParams struct { @@ -18779,6 +18861,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -18963,7 +19046,7 @@ const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata UPDATE media_items SET chapter_metadata = $2, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type UpdateMediaItemChapterMetadataParams struct { @@ -19064,6 +19147,7 @@ func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg Update &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -19163,7 +19247,7 @@ SET hash_confidence = $5, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type UpdateMediaItemIdentifiersParams struct { @@ -19278,6 +19362,7 @@ func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMedi &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -19290,7 +19375,7 @@ SET entitlement_id = $2, kobo_metadata = $5, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type UpdateMediaItemKoboMetadataParams struct { @@ -19400,6 +19485,7 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } @@ -20439,7 +20525,7 @@ ON CONFLICT (source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL DO U file_size = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_size ELSE EXCLUDED.file_size END, mime_type = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.mime_type ELSE EXCLUDED.mime_type END, updated_at = NOW() -RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts +RETURNING id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts, download_queue_pos ` type UpsertPodcastEpisodeParams struct { @@ -20601,6 +20687,7 @@ func (q *Queries) UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpi &i.DownloadState, &i.DownloadError, &i.DownloadAttempts, + &i.DownloadQueuePos, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 8298b0de..0228f3c7 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -4862,10 +4862,13 @@ SELECT * FROM media_items WHERE id = $1 AND source = 'podcast_episode'; -- name: SetPodcastEpisodeDownloadQueued :execrows --- Worker claim: pending|failed -> queued. Attempts incremented at --- claim time so a crashed worker still counts the try. +-- Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET +-- (§0 items 4/5) so claim order = enqueue order (the effective +-- listening order the enqueue loop applied). Attempts increment at +-- enqueue so a crashed worker still counts the try. UPDATE media_items SET download_state = 'queued', + download_queue_pos = nextval('podcast_queue_seq'), download_error = NULL, download_attempts = download_attempts + 1, updated_at = NOW() @@ -4873,10 +4876,9 @@ WHERE id = $1 AND source = 'podcast_episode' AND download_state IN ('pending', 'failed'); --- name: SetPodcastEpisodeDownloading :exec -UPDATE media_items -SET download_state = 'downloading', updated_at = NOW() -WHERE id = $1 AND source = 'podcast_episode'; +-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair +-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under +-- concurrency; ClaimQueuedPodcastEpisode does both atomically. -- name: MarkPodcastEpisodeDownloaded :one -- SUCCESS-only write (invariant 7): the file is on disk and probed. @@ -4964,12 +4966,26 @@ WHERE s.user_id = sqlc.arg('user_id') OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%') ORDER BY COALESCE(s.display_name, f.title) ASC; --- name: GetNextQueuedEpisode :one --- Sequential download worker's claim source (single worker, day one). -SELECT * FROM media_items +-- name: ListQueuedPodcastEpisodes :many +-- Fair-share candidate scan (§0 item 4): the download pool claims in +-- TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the +-- enqueue loop's ordering IS the download order, item 5). The worker +-- groups these per feed and prefers the least-recently-served feed. +SELECT id, source_feed_id FROM media_items WHERE source = 'podcast_episode' AND download_state = 'queued' -ORDER BY updated_at ASC -LIMIT 1; +ORDER BY download_queue_pos ASC NULLS LAST; + +-- name: ClaimQueuedPodcastEpisode :one +-- Atomic claim (§0 item 4): converts queued -> downloading in ONE +-- statement. Two claimants racing on the same row: exactly one UPDATE +-- matches download_state='queued'; the loser gets no rows (ErrNoRows). +-- This is the SKIP-LOCKED guarantee without a second round-trip. +UPDATE media_items +SET download_state = 'downloading', updated_at = NOW() +WHERE id = $1 + AND source = 'podcast_episode' + AND download_state = 'queued' +RETURNING media_items.*; -- name: CountQueuedEpisodes :one SELECT COUNT(*) FROM media_items diff --git a/internal/database/settings_registry.go b/internal/database/settings_registry.go index 8c9fb847..0107e33a 100644 --- a/internal/database/settings_registry.go +++ b/internal/database/settings_registry.go @@ -25,6 +25,7 @@ import ( // SettingType enumerates the value types stored in system_settings.setting_type. const ( SettingTypeInt = "int" + SettingTypeFloat = "float" SettingTypeBool = "bool" SettingTypeString = "string" SettingTypeStringList = "string_list" @@ -83,6 +84,8 @@ var SettingDefaults = []SettingDefault{ {Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"}, {Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"}, {Key: "podcast_poll_interval_seconds", Value: "3600", Type: SettingTypeInt, Min: "300", Max: "604800", Category: "podcasts", Group: "Podcasts", Description: "How often to poll podcast feeds for new episodes (seconds; complete feeds back off)"}, + {Key: "podcast_download_concurrency", Value: "4", Type: SettingTypeInt, Min: "1", Max: "16", Category: "podcasts", Group: "Podcasts", Description: "Podcast episode downloads in flight server-wide (the connection is the limit; fair-share spreads them across feeds)"}, + {Key: "podcast_download_speed_mbps", Value: "0", Type: SettingTypeFloat, Min: "0", Max: "10000", Category: "podcasts", Group: "Podcasts", Description: "Shared bandwidth cap for podcast downloads in megabits/second (0 = unlimited)"}, } // defaultBy indexes SettingDefaults by key for O(1) lookup. @@ -173,6 +176,26 @@ func (r *SettingsRegistry) getInt(key string) int { return n } +func (r *SettingsRegistry) getFloat(key string) float64 { + d := defaultBy[key] + v := r.raw(key) + f, err := strconv.ParseFloat(v, 64) + if err != nil { + f, _ = strconv.ParseFloat(d.Value, 64) + } + if d.Min != "" { + if mn, err := strconv.ParseFloat(d.Min, 64); err == nil && f < mn { + f = mn + } + } + if d.Max != "" { + if mx, err := strconv.ParseFloat(d.Max, 64); err == nil && f > mx { + f = mx + } + } + return f +} + func (r *SettingsRegistry) getBool(key string) bool { v := r.raw(key) b, err := strconv.ParseBool(v) @@ -189,13 +212,24 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration { return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second } -// PodcastPollInterval is the feed-poller tick (live setting; the poller -// re-reads it every cycle — writes go through the settings API path, -// AGENTS.md gotcha #2). +// PodcastPollInterval is how often the poller checks feeds, as a +// duration (live setting; the poller re-reads it every cycle — writes +// go through the settings API path, AGENTS.md gotcha #2). func (r *SettingsRegistry) PodcastPollInterval() time.Duration { return time.Duration(r.getInt("podcast_poll_interval_seconds")) * time.Second } +// PodcastDownloadConcurrency: episode downloads in flight server-wide +// (the connection is the limit; fair-share spreads them across feeds). +func (r *SettingsRegistry) PodcastDownloadConcurrency() int { + return r.getInt("podcast_download_concurrency") +} + +// PodcastDownloadSpeedMbps: shared bandwidth cap (0 = unlimited). +func (r *SettingsRegistry) PodcastDownloadSpeedMbps() float64 { + return r.getFloat("podcast_download_speed_mbps") +} + // AutoScanEnabled reports whether auto-scanning is on. func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") } diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go index 0928a006..34e28477 100644 --- a/internal/handlers/system_settings.go +++ b/internal/handlers/system_settings.go @@ -178,6 +178,21 @@ func validateSettingValue(def database.SettingDefault, value string) error { if _, err := strconv.ParseBool(value); err != nil { return fmt.Errorf("value must be true or false") } + case database.SettingTypeFloat: + f, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("value must be a number") + } + if def.Min != "" { + if mn, err := strconv.ParseFloat(def.Min, 64); err == nil && f < mn { + return fmt.Errorf("value must be >= %s", def.Min) + } + } + if def.Max != "" { + if mx, err := strconv.ParseFloat(def.Max, 64); err == nil && f > mx { + return fmt.Errorf("value must be <= %s", def.Max) + } + } case database.SettingTypeString: if value == "" { return fmt.Errorf("value must not be empty") diff --git a/internal/services/podcast_worker.go b/internal/services/podcast_worker.go index d6e4cc8e..ca161d69 100644 --- a/internal/services/podcast_worker.go +++ b/internal/services/podcast_worker.go @@ -1,11 +1,21 @@ -// Podcast download worker (plan §7 / D6 / D8 / D19): ONE sequential -// worker (day one) driving the episode state machine +// Podcast download worker (plan §7 / D6 / D8 / D19; pool since §0 item +// 4): a download-manager-style pool driving the episode state machine // // pending → queued → downloading → downloaded // ↘ failed (+ download_error) // // queued rows are the DB-backed worklist — the state is persisted so // the episode list ALWAYS renders truth (no in-memory queue to lose). +// Each queue ticket (download_queue_pos, a SEQUENCE nextval at +// enqueue) makes the enqueue loop's ordering the download order. +// +// Concurrency: up to podcast_download_concurrency episodes in flight +// server-wide (default 4, admin-tunable live 1–16). Fair share: the +// claim prefers the feed least-recently-served, so several books +// download in parallel while chapters within a book land in ticket +// order (start listening while the rest catch up). Bandwidth: an +// optional shared token bucket (podcast_download_speed_mbps, 0 = +// unlimited) paces all workers together. // // Idempotency: a download request for an episode whose file already // exists and probes cleanly re-marks it downloaded without re-fetching @@ -29,10 +39,10 @@ import ( "fmt" "io" "log" - "os" - "path/filepath" "net/http" "net/url" + "os" + "path/filepath" "strings" "sync" "time" @@ -48,25 +58,64 @@ const maxAutoDownloadAttempts = 5 // PodcastDownloadWorker processes queued episode downloads. type PodcastDownloadWorker struct { - db *database.Queries - svc *PodcastService - wake chan struct{} + db *database.Queries + svc *PodcastService + settings *database.SettingsRegistry + wake chan struct{} + + // Fair-share bookkeeping (§0 item 4): feed UUID string → last + // time the pool served it. Claim prefers the smallest value; + // never-served feeds (missing key) go first. Guarded by fairMu. + fairMu sync.Mutex + lastServed map[string]time.Time + + // Shared bandwidth bucket across all pool workers (§0 item 4). + limiter bandwidthLimiter // Live byte-progress slots for in-flight downloads (issue #1). // Keyed by episode UUID string; guarded by progMu. - progMu sync.Mutex + progMu sync.Mutex progress map[string]*episodeProgress } func NewPodcastDownloadWorker(db *database.Queries, svc *PodcastService) *PodcastDownloadWorker { return &PodcastDownloadWorker{ - db: db, - svc: svc, - wake: make(chan struct{}, 1), - progress: make(map[string]*episodeProgress), + db: db, + svc: svc, + wake: make(chan struct{}, 1), + lastServed: make(map[string]time.Time), + progress: make(map[string]*episodeProgress), } } +// SetSettings wires the live settings registry (admin tunables: +// concurrency + bandwidth cap). Without it the worker runs on the +// registered defaults (4 in flight, unlimited speed) — the test +// harness constructs workers without settings. +func (w *PodcastDownloadWorker) SetSettings(r *database.SettingsRegistry) { + w.settings = r +} + +// concurrency re-reads the live setting each drain cycle. +func (w *PodcastDownloadWorker) concurrency() int { + if w.settings == nil { + return 4 // registered default + } + if n := w.settings.PodcastDownloadConcurrency(); n >= 1 { + return n + } + return 4 +} + +// refillLimiter re-syncs the shared bucket's rate (bytes/sec) from the +// live setting; 0 = unlimited. +func (w *PodcastDownloadWorker) refillLimiter() { + if w.settings == nil { + return + } + w.limiter.setRate(int64(w.settings.PodcastDownloadSpeedMbps() * 125000)) // Mbps → bytes/sec +} + // Wake nudges the loop (new work queued). func (w *PodcastDownloadWorker) Wake() { select { @@ -93,21 +142,129 @@ func (w *PodcastDownloadWorker) Start(ctx context.Context) { }() } -// drain processes queued episodes until the queue is empty. +// DrainNow runs one bounded pool round synchronously — the test hook +// and the future admin "retry now" seam (normally the wake channel +// drives rounds). +func (w *PodcastDownloadWorker) DrainNow(ctx context.Context) { + w.drain(ctx) +} + +// drain runs one pool round: up to concurrency() claim-loop goroutines +// each claim (fair-share) → download → settle, until the queue is +// empty. The cap setting is re-read every round — live-tunable. func (w *PodcastDownloadWorker) drain(ctx context.Context) { - for { - if ctx.Err() != nil { - return - } - acted, err := w.processNext(ctx) + if ctx.Err() != nil { + return + } + w.refillLimiter() + limit := w.concurrency() + var wg sync.WaitGroup + for i := 0; i < limit; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + if ctx.Err() != nil { + return + } + ep, ok := w.claimNext(ctx) + if !ok { + return // queue drained (or nothing claimable) + } + w.runOne(ctx, ep) + } + }() + } + wg.Wait() +} + +// claimNext atomically claims the next queued episode, preferring the +// feed least-recently-served (fair share — books proceed in parallel) +// and the earliest queue ticket within a feed (chapters land in +// enqueue order). Returns ok=false when nothing is claimable. +func (w *PodcastDownloadWorker) claimNext(ctx context.Context) (database.MediaItems, bool) { + actx, err := w.svc.adminCtx(ctx) + if err != nil { + return database.MediaItems{}, false + } + cands, err := w.db.ListQueuedPodcastEpisodes(actx) + if err != nil || len(cands) == 0 { + return database.MediaItems{}, false + } + + // Group per feed preserving ticket order (the SQL scan is ordered). + perFeed := make(map[pgtype.UUID][]pgtype.UUID, 8) + for _, c := range cands { + perFeed[c.SourceFeedID] = append(perFeed[c.SourceFeedID], c.ID) + } + + w.fairMu.Lock() + defer w.fairMu.Unlock() + pickFeed := pickFeedToServe(perFeed, w.lastServed) + if !pickFeed.Valid { + return database.MediaItems{}, false + } + + // Claim the head ticket of the chosen feed; if another claimant + // won it meanwhile (retry raced), fall through the feed's tickets. + for _, id := range perFeed[pickFeed] { + ep, err := w.db.ClaimQueuedPodcastEpisode(actx, id) if err != nil { - log.Printf("[PODCAST-DL] worker step: %v", err) - return + continue // claimed elsewhere / state moved on } - if !acted { - return + w.lastServed[pickFeed.String()] = time.Now() + return ep, true + } + return database.MediaItems{}, false +} + +// pickFeedToServe chooses the feed least-recently-served (never-served +// first); among never-served feeds the first encountered wins (any is +// equally fair). Unit-testable seam for the fair-share rule. +func pickFeedToServe(perFeed map[pgtype.UUID][]pgtype.UUID, lastServed map[string]time.Time) pgtype.UUID { + var pick pgtype.UUID + var best time.Time + haveNever := false + for feed := range perFeed { + last, seen := lastServed[feed.String()] + if !seen { + if !haveNever { + pick, haveNever = feed, true + } + continue + } + if haveNever { + continue // a never-served feed already won + } + if !pick.Valid || last.Before(best) { + pick, best = feed, last } } + return pick +} + +// runOne downloads a claimed episode and settles the row either way +// (failed/rejected: state + reason persist; metadata/file facts +// untouched — invariant 7, write-after-success only). +func (w *PodcastDownloadWorker) runOne(ctx context.Context, ep database.MediaItems) { + actx, err := w.svc.adminCtx(ctx) + if err != nil { + return + } + if err := w.runPipeline(actx, ep); err != nil { + _ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{ + ID: ep.ID, + DownloadError: pgtypeText(err.Error()), + Column3: false, // auto failure: never resets the attempt budget + }) + log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err) + } +} + +// AdminCtx exposes the worker's first-admin identity (tests use it for +// worker-scope DB statements). +func (w *PodcastDownloadWorker) AdminCtx(ctx context.Context) (context.Context, error) { + return w.svc.adminCtx(ctx) } // QueueEpisode marks one episode queued (manual download / retry path). @@ -228,33 +385,6 @@ func (w *PodcastDownloadWorker) ApplyAutoPolicies(ctx context.Context, feedID pg return queued, nil } -// processNext claims and processes one queued episode. Returns false -// when the queue was empty. -func (w *PodcastDownloadWorker) processNext(ctx context.Context) (bool, error) { - actx, err := w.svc.adminCtx(ctx) - if err != nil { - return false, err - } - ep, err := w.db.GetNextQueuedEpisode(actx) - if err != nil { - return false, nil // empty queue (or read failure — logged upstream) - } - - _ = w.db.SetPodcastEpisodeDownloading(actx, ep.ID) - - if err := w.runPipeline(actx, ep); err != nil { - // failed/rejected: state + reason persist; metadata/file facts - // untouched (invariant 7 — write-after-success only). - _ = w.db.MarkPodcastEpisodeFailed(actx, database.MarkPodcastEpisodeFailedParams{ - ID: ep.ID, - DownloadError: pgtypeText(err.Error()), - Column3: false, // auto failure: never resets the attempt budget - }) - log.Printf("[PODCAST-DL] episode %s failed: %v", ep.ID, err) - } - return true, nil -} - // runPipeline: fetch enclosure (SSRF/size guards) → temp file → MIME/ // size validation → ffprobe reconcile → move into place → SUCCESS-only // row update. @@ -398,6 +528,42 @@ func (w *PodcastDownloadWorker) EpisodeProgress(episodeID pgtype.UUID) (received return p.Received, p.Total, true } +// ---- shared bandwidth bucket (§0 item 4) ---- +// One leaky-bucket-with-reservations limiter across ALL pool workers: +// each caller reserves the next slot of bandwidth and sleeps outside +// the lock — reservation order = transmission order, and N workers +// share one cap instead of N caps. + +type bandwidthLimiter struct { + mu sync.Mutex + rate int64 // bytes per second; <=0 = unlimited + next time.Time // when the currently-reserved budget runs out +} + +func (l *bandwidthLimiter) setRate(bytesPerSec int64) { + l.mu.Lock() + defer l.mu.Unlock() + l.rate = bytesPerSec +} + +// take reserves n bytes of bandwidth and returns how long the caller +// must wait before sending them (0 when free flow). +func (l *bandwidthLimiter) take(n int64) time.Duration { + l.mu.Lock() + defer l.mu.Unlock() + if l.rate <= 0 { + return 0 + } + now := time.Now() + if l.next.Before(now) { + l.next = now + } + d := time.Duration(float64(n) / float64(l.rate) * float64(time.Second)) + start := l.next // my window begins when earlier reservations end + l.next = start.Add(d) + return start.Sub(now) +} + // streamEnclosure performs the SSRF-guarded enclosure download straight // to tmpPath while updating the progress slot. Returns bytes received // and the authoritative total (0 when unknown). Scheme and size guards @@ -456,6 +622,15 @@ func (w *PodcastDownloadWorker) streamEnclosure(ctx context.Context, ep database if received > maxEnclosureCapBytes { return received, total, fmt.Errorf("rejected: enclosure exceeds %d byte cap", maxEnclosureCapBytes) } + // Shared bandwidth cap: pace against the server-wide + // bucket (0 rate = unlimited, take returns 0). + if wait := w.limiter.take(int64(n)); wait > 0 { + select { + case <-ctx.Done(): + return received, total, fmt.Errorf("download failed: cancelled") + case <-time.After(wait): + } + } w.addProgress(ep.ID, int64(n)) if _, werr := out.Write(buf[:n]); werr != nil { return received, total, fmt.Errorf("write: %w", werr) @@ -496,13 +671,13 @@ func (w *PodcastDownloadWorker) markDownloaded(ctx context.Context, ep database. } _, err = w.db.MarkPodcastEpisodeDownloaded(ctx, database.MarkPodcastEpisodeDownloadedParams{ - ID: ep.ID, - FilePath: ep.FilePath, - FileSize: pgtypeInt8(info.Size()), - MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))), + ID: ep.ID, + FilePath: ep.FilePath, + FileSize: pgtypeInt8(info.Size()), + MimeType: pgtypeText(strings.TrimSpace(pickMimeType(ep, absPath))), CoverImagePath: coverRel, - AudioMetadata: audioPayloadJSONB(payload), - ChapterCount: chapterCount, + AudioMetadata: audioPayloadJSONB(payload), + ChapterCount: chapterCount, }) if err != nil { return fmt.Errorf("mark downloaded: %w", err) diff --git a/internal/services/podcast_worker_test.go b/internal/services/podcast_worker_test.go new file mode 100644 index 00000000..f8945808 --- /dev/null +++ b/internal/services/podcast_worker_test.go @@ -0,0 +1,80 @@ +// Unit tests for the §0 item 4 download-pool mechanics: the shared +// bandwidth bucket (reservation math, unlimited passthrough) and the +// fair-share feed pick (never-served first, least-recently-served +// after). Pure logic — no DB. +package services + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" +) + +func uuidOf(s string) pgtype.UUID { + var u pgtype.UUID + _ = u.Scan(s) + return u +} + +func TestBandwidthLimiter(t *testing.T) { + t.Run("zero rate is unlimited", func(t *testing.T) { + var l bandwidthLimiter + l.setRate(0) + assert.Zero(t, l.take(1<<20), "unlimited → no wait, ever") + assert.Zero(t, l.take(1<<20)) + }) + + t.Run("reservations share one bucket", func(t *testing.T) { + var l bandwidthLimiter + l.setRate(1_000_000) // 1 MB/s + start := time.Now() + + w1 := l.take(1_000_000) // first caller: free flow + w2 := l.take(1_000_000) // second caller: waits one full second + w3 := l.take(1_000_000) // third caller: waits two seconds + + assert.Less(t, int64(w1), int64(50*time.Millisecond), "first reservation starts immediately") + assert.GreaterOrEqual(t, int64(w2), int64(900*time.Millisecond), "second waits ~1s at 1MB/s") + assert.GreaterOrEqual(t, int64(w3), int64(1900*time.Millisecond), "third waits ~2s — SHARED bucket, not per-worker") + assert.Less(t, time.Since(start), 100*time.Millisecond, "take() only reserves — callers sleep outside the lock") + }) + + t.Run("idle bucket recovers", func(t *testing.T) { + var l bandwidthLimiter + l.setRate(1_000_000) + _ = l.take(1_000_000) + l.next = time.Now().Add(-time.Second) // simulate a second of idle flow + assert.Less(t, int64(l.take(500_000)), int64(50*time.Millisecond), + "budget accrued while idle → next take flows immediately") + }) +} + +func TestPickFeedToServe(t *testing.T) { + a, b, c := uuidOf("00000000-0000-0000-0000-00000000000a"), + uuidOf("00000000-0000-0000-0000-00000000000b"), + uuidOf("00000000-0000-0000-0000-00000000000c") + all := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}, c: {}} + + t.Run("never-served beats served", func(t *testing.T) { + served := map[string]time.Time{ + a.String(): time.Now().Add(-time.Hour), + b.String(): time.Now().Add(-time.Minute), + } + assert.Equal(t, c, pickFeedToServe(all, served), "c was never served → c wins even though a starved longest") + }) + + t.Run("least-recently-served wins", func(t *testing.T) { + served := map[string]time.Time{ + a.String(): time.Now().Add(-time.Hour), // starved longest + b.String(): time.Now().Add(-time.Minute), // served recently + } + two := map[pgtype.UUID][]pgtype.UUID{a: {}, b: {}} + assert.Equal(t, a, pickFeedToServe(two, served)) + }) + + t.Run("empty queue picks nothing", func(t *testing.T) { + assert.False(t, pickFeedToServe(map[pgtype.UUID][]pgtype.UUID{}, nil).Valid) + }) +} diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index 4f36160b..761a96b3 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -244,6 +244,20 @@ templ TunableSettingRow(e SettingEntry) { } class="input py-1.5 text-sm w-32" /> + } else if e.Type == "float" { + } else { ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if e.Min != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " min=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 254, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if e.Max != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " max=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 257, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, " class=\"input py-1.5 text-sm w-32\"> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"text-xs w-24 text-right\" style=\"color: var(--text-secondary)\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -880,12 +940,12 @@ func AboutSection() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -893,20 +953,20 @@ func AboutSection() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

About

Version: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "

About

Version: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version) + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 288, Col: 134} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }