advplyr / audiobookshelf

Self-hosted audiobook and podcast server
https://audiobookshelf.org
GNU General Public License v3.0
6.8k stars 481 forks source link

[Enhancement]: Alphabetic navigation of alphabetically sorted lists #3446

Open pwinnski opened 1 month ago

pwinnski commented 1 month ago

Type of Enhancement

Web Interface/Frontend

Describe the Feature/Enhancement

When presented with a list that is sorted alphabetically, typing a letter should scroll to the first item starting with that letter.

Why would this be helpful?

A list with hundreds or thousands of items may take a long time to scroll, and depending on caching, it may not be clear where to stop scrolling. A Series listing is just unlabeled rectangles on a cache miss: image And the same is true of a Library listing: image If I can press 'R' to jump to the first book title or author name (depending on sort) or series name that starts with a 'R', that would speed things up considerably.

Future Implementation (Screenshot)

There doesn't need to be any visual change to the UI at all. Pressing 'R' (for example) would scroll to here: image Because 'Rains, The' is the first series that "starts" with R.

Audiobookshelf Server Version

v2.13.4

Current Implementation (Screenshot)

Pressing any letter key leaves me at the top of the list. image

DDriggs00 commented 1 month ago

The typical implementation of this is to add a vertical bar on the right, next to the scroll bar with clickable letters on it. Clicking a letter either opens up a filtered view or scrolls to the desired location.

pwinnski commented 1 month ago

Indeed, while there does not need to be a visual change to the UI, this is an example from another program: image

nichwall commented 1 month ago

I'm not able to find the feature request at the moment, but this has the same issue as the alphabetical scroll bar feature request, where the client also does not know where to jump to. The client knows how many total items are in the library/filter, but does not know the breakdown of those items until the page is requested for getting the item information and cover images.

pwinnski commented 1 month ago

That does make this a more complicated request then, because it would need to start with being able to get offsets by letter or something similar. Hmmm.

pwinnski commented 1 month ago

Re-stating the issue: with a library of 6000+ audiobooks, scrolling through a name-sorted list is currently slow to the point of unusable. If I use the scrollbar to jump down quite a bit in search of books with a title beginning with T, I can drag the scrollbar, let go, and then wait to see how far I've missed, and will have to navigate more to get to the section I'm seeking.

I just tried this: The log contains quite a few "Cache miss" lines and shows queries for pages 1, 2, 4, 5, 3, 6, 7, 22, 41, 23, 42, 112, 113, 117, 115, 114, 105, 106, 104, and 103, in that order. These lines start at :58:17.008 and end at :59:40.551, 83 seconds from first query to last. Again, only after all of these complete can I even see how close I got to where I was seeking, at which point I have to scroll again.

Delays of 20+ seconds are commonplace, but delays long enough to brew a coffee are extreme!

Without any consideration of how to work within the current ApiCacheManager framework, if I were writing API code for this function alone, I would think there should be a way to return the relevant page of results by calculating the offset. For example:

time echo "
    SELECT b.id,
           b.title,
           b.subtitle,
           b.coverPath,
           GROUP_CONCAT(a.name, ', ') authors,
           s.name, 
           bs.sequence
      FROM books b,
           bookAuthors ba ON ba.bookId = b.id,
           authors a ON a.id = ba.authorId
 LEFT JOIN bookSeries bs ON b.id = bs.bookId
 LEFT JOIN series s ON bs.seriesId = s.id
  GROUP BY b.id
  ORDER BY b.titleIgnorePrefix
     LIMIT 54
    OFFSET (SELECT offset_value 
              FROM (SELECT (COUNT(*) / 54) * 54 AS offset_value 
                      FROM books 
                     WHERE titleIgnorePrefix < 'L'));
" | sqlite3 config/absdatabase.sqlite
[skipped]

real    0m0.094s
user    0m0.068s
sys 0m0.025s

In this case, the 54 is from my daily log, and seems to be based on my display being set to four rows of nine, so it's set to six rows of nine, one before and one after my displayed rows. Let that be {page_size} and the letter someone has typed or clicked be {letter} and you get:

    SELECT b.id,
           b.title,
           b.subtitle,
           b.coverPath,
           GROUP_CONCAT(a.name, ', ') authors,
           s.name, 
           bs.sequence
      FROM books b,
           bookAuthors ba ON ba.bookId = b.id,
           authors a ON a.id = ba.authorId
 LEFT JOIN bookSeries bs ON b.id = bs.bookId
 LEFT JOIN series s ON bs.seriesId = s.id
  GROUP BY b.id
  ORDER BY b.titleIgnorePrefix
     LIMIT {page_size}
    OFFSET (SELECT offset_value 
              FROM (SELECT (COUNT(*) / {page_size}) * {page_size} AS offset_value
                      FROM books
                     WHERE titleIgnorePrefix < '{letter}'));

On a completely-cold container, I've seen that take 6-7 seconds, but it's normally less than one-tenth of a second.

I know things get more complex when you have to filter for explicit content and allowed tags, and I just realized I'm not even limiting to a single library here, but that general approach would be ideal, IF ABS were set up to use direct queries like that. Instead everything goes through Sequelize, and next I'll look into how that works, and what queries are actually being done.

pwinnski commented 1 month ago

To limit to a single library, replace:

      FROM books b,
           bookAuthors ba ON ba.bookId = b.id,

with

      FROM books b,
           libraryItems l ON (b.id = l.mediaId AND l.libraryId = {library_id}),
           bookAuthors ba ON ba.bookId = b.id,

And also in the OFFSET clause, replace:

                      FROM books

with

                      FROM books b,
                           libraryItems l ON (b.id = l.mediaId AND l.libraryId = {library_id})