Repository: internetarchive/openlibrary

Issue:
Books no longer selecting a readable default edition
Previously, going to a work url would select a readable edition, and the url would get a `?edition=` parameter. Now it does not.

Likely introduced as part of #5271 , in the most recent deploy.

### Relevant url?
- eg https://openlibrary.org/works/OL102584W
- eg https://openlibrary.org/works/OL13537898W/Tales_of_Mystery_and_Imagination

### Steps to Reproduce
1. Go to https://openlibrary.org/works/OL102584W

<!-- What actually happened after these steps? What did you expect to happen? -->
* Actual: A non-readable edition selected
* Expected: A readable edition to be selected

### Details

- **Logged in (Y/N)?** Y
- **Browser type/version?** FF93
- **Operating system?** Win10
- **Environment (prod/dev/local)?** prod
<!-- If not sure, put prod -->

### Proposal & Constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->

### Related files
<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->

### Stakeholders
@seabelis



Discussion:

Resolving patch:
diff --git a/openlibrary/book_providers.py b/openlibrary/book_providers.py
--- a/openlibrary/book_providers.py
+++ b/openlibrary/book_providers.py
@@ -1,4 +1,4 @@
-from typing import List, Optional, Union, Literal, cast
+from typing import Optional, Union, Literal, cast
 
 import web
 from web import uniq
@@ -6,6 +6,7 @@
 from openlibrary.app import render_template
 from openlibrary.plugins.upstream.models import Edition
 from openlibrary.plugins.upstream.utils import get_coverstore_public_url
+from openlibrary.utils import multisort_best
 
 
 class AbstractBookProvider:
@@ -186,6 +187,33 @@ def get_book_provider_by_name(short_name: str) -> Optional[AbstractBookProvider]
     return next((p for p in PROVIDER_ORDER if p.short_name == short_name), None)
 
 
+ia_provider = cast(InternetArchiveProvider, get_book_provider_by_name('ia'))
+prefer_ia_provider_order = uniq([ia_provider, *PROVIDER_ORDER])
+
+
+def get_provider_order(prefer_ia=False) -> list[AbstractBookProvider]:
+    default_order = prefer_ia_provider_order if prefer_ia else PROVIDER_ORDER
+
+    provider_order = default_order
+    provider_overrides = web.input(providerPref=None).providerPref
+    if provider_overrides:
+        new_order: list[AbstractBookProvider] = []
+        for name in provider_overrides.split(','):
+            if name == '*':
+                new_order += default_order
+            else:
+                provider = get_book_provider_by_name(name)
+                if not provider:
+                    # TODO: Show the user a warning somehow
+                    continue
+                new_order.append(provider)
+        new_order = uniq(new_order + default_order)
+        if new_order:
+            provider_order = new_order
+
+    return provider_order
+
+
 def get_book_provider(
     ed_or_solr: Union[Edition, dict]
 ) -> Optional[AbstractBookProvider]:
@@ -198,7 +226,6 @@ def get_book_provider(
     # OCAIDs that look like they're from other providers.
     prefer_ia = not isinstance(ed_or_solr, Edition)
     if prefer_ia:
-        ia_provider = cast(InternetArchiveProvider, get_book_provider_by_name('ia'))
         ia_ocaids = [
             ocaid
             # Subjects/publisher pages have ia set to a specific value :/
@@ -207,28 +234,7 @@ def get_book_provider(
         ]
         prefer_ia = bool(ia_ocaids)
 
-    default_order = PROVIDER_ORDER
-    if prefer_ia:
-        ia_provider = cast(InternetArchiveProvider, get_book_provider_by_name('ia'))
-        default_order = uniq([ia_provider, *PROVIDER_ORDER])
-
-    provider_order = default_order
-    provider_overrides = web.input(providerPref=None).providerPref
-
-    if provider_overrides:
-        new_order: list[AbstractBookProvider] = []
-        for name in provider_overrides.split(','):
-            if name == '*':
-                new_order += default_order
-            else:
-                provider = get_book_provider_by_name(name)
-                if not provider:
-                    # TODO: Show the user a warning somehow
-                    continue
-                new_order.append(provider)
-        new_order = uniq(new_order + default_order)
-        if new_order:
-            provider_order = new_order
+    provider_order = get_provider_order(prefer_ia)
 
     for provider in provider_order:
         if provider.get_identifiers(ed_or_solr):
@@ -238,4 +244,32 @@ def get_book_provider(
     return None
 
 
+def get_best_edition(
+        editions: list[Edition]
+) -> tuple[Optional[Edition], Optional[AbstractBookProvider]]:
+    provider_order = get_provider_order(True)
+
+    # Map provider name to position/ranking
+    provider_rank_lookup: dict[Optional[AbstractBookProvider], int] = {
+        provider: i
+        for i, provider in enumerate(provider_order)
+    }
+
+    # Here, we prefer the ia editions
+    augmented_editions = [
+        (edition, get_book_provider(edition))
+        for edition in editions
+    ]
+
+    best = multisort_best(augmented_editions, [
+        # Prefer the providers closest to the top of the list
+        ('min', lambda rec: provider_rank_lookup.get(rec[1], float('inf'))),
+        # Prefer the editions with the most fields
+        ('max', lambda rec: len(dict(rec[0]))),
+        # TODO: Language would go in this queue somewhere
+    ])
+
+    return best if best else (None, None)
+
+
 setattr(get_book_provider, 'ia', get_book_provider_by_name('ia'))
diff --git a/openlibrary/plugins/openlibrary/code.py b/openlibrary/plugins/openlibrary/code.py
--- a/openlibrary/plugins/openlibrary/code.py
+++ b/openlibrary/plugins/openlibrary/code.py
@@ -1077,6 +1077,7 @@ def setup_template_globals():
     # order, resulting in random errors like the the /account/login.json endpoint
     # defined in accounts.py being ignored, and using the infogami endpoint instead.
     from openlibrary.book_providers import (
+        get_best_edition,
         get_book_provider,
         get_book_provider_by_name,
         get_cover_url,
@@ -1096,6 +1097,7 @@ def setup_template_globals():
             'random': random.Random(),
             'get_lang': lambda: web.ctx.lang,
             'ceil': math.ceil,
+            'get_best_edition': get_best_edition,
             'get_book_provider': get_book_provider,
             'get_book_provider_by_name': get_book_provider_by_name,
             'get_cover_url': get_cover_url,
diff --git a/openlibrary/utils/__init__.py b/openlibrary/utils/__init__.py
--- a/openlibrary/utils/__init__.py
+++ b/openlibrary/utils/__init__.py
@@ -2,7 +2,7 @@
 
 import re
 from subprocess import PIPE, Popen, STDOUT
-from typing import TypeVar, Iterable, List
+from typing import TypeVar, Iterable, Literal, Callable, Optional
 
 to_drop = set(''';/?:@&=+$,<>#%"{}|\\^[]`\n\r''')
 
@@ -54,6 +54,67 @@ def uniq(values: Iterable[T], key=None) -> list[T]:
     return result
 
 
+def take_best(
+    items: list[T],
+    optimization: Literal["min", "max"],
+    scoring_fn: Callable[[T], float],
+) -> list[T]:
+    """
+    >>> take_best([], 'min', lambda x: x)
+    []
+    >>> take_best([3, 2, 1], 'min', lambda x: x)
+    [1]
+    >>> take_best([3, 4, 5], 'max', lambda x: x)
+    [5]
+    >>> take_best([4, 1, -1, -1], 'min', lambda x: x)
+    [-1, -1]
+    """
+    best_score = float("-inf") if optimization == "max" else float("inf")
+    besties = []
+    for item in items:
+        score = scoring_fn(item)
+        if (optimization == "max" and score > best_score) or (
+            optimization == "min" and score < best_score
+        ):
+            best_score = score
+            besties = [item]
+        elif score == best_score:
+            besties.append(item)
+        else:
+            continue
+    return besties
+
+
+def multisort_best(
+    items: list[T], specs: list[tuple[Literal["min", "max"], Callable[[T], float]]]
+) -> Optional[T]:
+    """
+    Takes the best item, taking into account the multisorts
+
+    >>> multisort_best([], [])
+
+    >>> multisort_best([3,4,5], [('max', lambda x: x)])
+    5
+
+    >>> multisort_best([
+    ...     {'provider': 'ia', 'size': 4},
+    ...     {'provider': 'ia', 'size': 12},
+    ...     {'provider': None, 'size': 42},
+    ... ], [
+    ...     ('min', lambda x: 0 if x['provider'] == 'ia' else 1),
+    ...     ('max', lambda x: x['size']),
+    ... ])
+    {'provider': 'ia', 'size': 12}
+    """
+    if not items:
+        return None
+    pool = items
+    for optimization, fn in specs:
+        # Shrink the pool down each time
+        pool = take_best(pool, optimization, fn)
+    return pool[0]
+
+
 def dicthash(d):
     """Dictionaries are not hashable. This function converts dictionary into nested
     tuples, so that it can hashed.


Test patch: