|
13 | 13 | union_all, |
14 | 14 | update, |
15 | 15 | ) |
| 16 | +from sqlalchemy.engine import Row |
16 | 17 | from sqlalchemy.exc import IntegrityError |
17 | 18 | from sqlalchemy.orm import ( |
18 | 19 | Query, |
|
23 | 24 | selectinload, |
24 | 25 | ) |
25 | 26 |
|
| 27 | +from config import FRONTEND_RESOURCES_PATH |
26 | 28 | from decorators.database import begin_session |
27 | 29 | from models.collection import ( |
| 30 | + SMART_COLLECTION_MAX_COVERS, |
28 | 31 | Collection, |
29 | 32 | CollectionRom, |
30 | 33 | SmartCollection, |
|
42 | 45 | COVERS_BATCH_SIZE = 100 |
43 | 46 |
|
44 | 47 |
|
| 48 | +def _strip_cache_buster(urls: Sequence[str]) -> list[str]: |
| 49 | + return [url.split("?", 1)[0] for url in urls] |
| 50 | + |
| 51 | + |
45 | 52 | def with_roms(func): |
46 | 53 | @functools.wraps(func) |
47 | 54 | def wrapper(*args, **kwargs): |
@@ -466,80 +473,162 @@ def delete_smart_collection( |
466 | 473 | .execution_options(synchronize_session="evaluate") |
467 | 474 | ) |
468 | 475 |
|
469 | | - def get_smart_collection_roms( |
470 | | - self, smart_collection: SmartCollection, user_id: int | None = None |
471 | | - ) -> Sequence["Rom"]: |
472 | | - """Get ROMs that match the smart collection's filter criteria.""" |
473 | | - from handler.database import db_rom_handler |
| 476 | + def get_smart_collection_criteria( |
| 477 | + self, smart_collection: SmartCollection |
| 478 | + ) -> dict[str, Any]: |
| 479 | + """Translate stored filter criteria into `filter_roms` keyword arguments. |
474 | 480 |
|
475 | | - # Extract filter criteria |
| 481 | + `smart_collection_id` is dropped: the create dialog records the route it |
| 482 | + was opened from, so a smart collection built while viewing another one |
| 483 | + carries that id, and following it would nest (and could cycle). |
| 484 | + """ |
476 | 485 | criteria = smart_collection.filter_criteria |
477 | 486 |
|
478 | | - # Convert legacy single-value criteria to arrays for backward compatibility |
479 | | - def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None: |
480 | | - """Convert legacy single-value filter to array format.""" |
481 | | - if new_value := criteria.get(new_key): |
482 | | - return new_value if isinstance(new_value, list) else [new_value] |
483 | | - if old_value := criteria.get(old_key): |
484 | | - return old_value if isinstance(old_value, list) else [old_value] |
485 | | - return None |
| 487 | + # Early versions stored single values under `selected_*` keys. |
| 488 | + def as_list(new_key: str, old_key: str) -> list[str] | None: |
| 489 | + value = criteria.get(new_key) or criteria.get(old_key) |
| 490 | + if not value: |
| 491 | + return None |
| 492 | + return value if isinstance(value, list) else [value] |
486 | 493 |
|
487 | | - # Apply conversions |
488 | | - genres = convert_legacy_filter("genres", "selected_genre") |
489 | | - franchises = convert_legacy_filter("franchises", "selected_franchise") |
490 | | - collections = convert_legacy_filter("collections", "selected_collection") |
491 | | - companies = convert_legacy_filter("companies", "selected_company") |
492 | | - age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating") |
493 | | - regions = convert_legacy_filter("regions", "selected_region") |
494 | | - languages = convert_legacy_filter("languages", "selected_language") |
495 | | - tags = convert_legacy_filter("tags", "selected_tag") |
496 | | - statuses = convert_legacy_filter("statuses", "selected_status") |
497 | | - |
498 | | - # Use the existing filter_roms method with the stored criteria |
499 | 494 | platform_ids = criteria.get("platform_ids") |
500 | | - if platform_ids is None: |
501 | | - if platform_id := criteria.get("platform_id"): |
502 | | - platform_ids = [platform_id] |
503 | | - |
504 | | - return db_rom_handler.get_roms_scalar( |
505 | | - platform_ids=platform_ids, |
506 | | - collection_id=criteria.get("collection_id"), |
507 | | - virtual_collection_id=criteria.get("virtual_collection_id"), |
508 | | - search_term=criteria.get("search_term"), |
509 | | - matched=criteria.get("matched"), |
510 | | - favorite=criteria.get("favorite"), |
511 | | - duplicate=criteria.get("duplicate"), |
512 | | - playable=criteria.get("playable"), |
513 | | - has_ra=criteria.get("has_ra"), |
514 | | - has_saves=criteria.get("has_saves"), |
515 | | - has_states=criteria.get("has_states"), |
516 | | - has_soundtrack=criteria.get("has_soundtrack"), |
517 | | - missing=criteria.get("missing"), |
518 | | - verified=criteria.get("verified"), |
519 | | - genres=genres, |
520 | | - franchises=franchises, |
521 | | - collections=collections, |
522 | | - companies=companies, |
523 | | - age_ratings=age_ratings, |
524 | | - statuses=statuses, |
525 | | - regions=regions, |
526 | | - languages=languages, |
527 | | - player_counts=criteria.get("player_counts"), |
528 | | - tags=tags, |
529 | | - metadata_providers=criteria.get("metadata_providers"), |
530 | | - # Logic operators for multi-value filters |
531 | | - genres_logic=criteria.get("genres_logic", "any"), |
532 | | - franchises_logic=criteria.get("franchises_logic", "any"), |
533 | | - collections_logic=criteria.get("collections_logic", "any"), |
534 | | - companies_logic=criteria.get("companies_logic", "any"), |
535 | | - age_ratings_logic=criteria.get("age_ratings_logic", "any"), |
536 | | - regions_logic=criteria.get("regions_logic", "any"), |
537 | | - languages_logic=criteria.get("languages_logic", "any"), |
538 | | - player_counts_logic=criteria.get("player_counts_logic", "any"), |
539 | | - statuses_logic=criteria.get("statuses_logic", "any"), |
540 | | - metadata_providers_logic=criteria.get("metadata_providers_logic", "any"), |
541 | | - tags_logic=criteria.get("tags_logic", "any"), |
542 | | - user_id=user_id, |
| 495 | + if platform_ids is None and (platform_id := criteria.get("platform_id")): |
| 496 | + platform_ids = [platform_id] |
| 497 | + |
| 498 | + return { |
| 499 | + "platform_ids": platform_ids, |
| 500 | + "collection_id": criteria.get("collection_id"), |
| 501 | + "virtual_collection_id": criteria.get("virtual_collection_id"), |
| 502 | + "search_term": criteria.get("search_term"), |
| 503 | + "matched": criteria.get("matched"), |
| 504 | + "favorite": criteria.get("favorite"), |
| 505 | + "duplicate": criteria.get("duplicate"), |
| 506 | + "playable": criteria.get("playable"), |
| 507 | + "has_ra": criteria.get("has_ra"), |
| 508 | + "has_saves": criteria.get("has_saves"), |
| 509 | + "has_states": criteria.get("has_states"), |
| 510 | + "has_soundtrack": criteria.get("has_soundtrack"), |
| 511 | + "missing": criteria.get("missing"), |
| 512 | + "verified": criteria.get("verified"), |
| 513 | + "genres": as_list("genres", "selected_genre"), |
| 514 | + "franchises": as_list("franchises", "selected_franchise"), |
| 515 | + "collections": as_list("collections", "selected_collection"), |
| 516 | + "companies": as_list("companies", "selected_company"), |
| 517 | + "age_ratings": as_list("age_ratings", "selected_age_rating"), |
| 518 | + "regions": as_list("regions", "selected_region"), |
| 519 | + "languages": as_list("languages", "selected_language"), |
| 520 | + "tags": as_list("tags", "selected_tag"), |
| 521 | + "statuses": as_list("statuses", "selected_status"), |
| 522 | + "player_counts": criteria.get("player_counts"), |
| 523 | + "metadata_providers": criteria.get("metadata_providers"), |
| 524 | + "genres_logic": criteria.get("genres_logic", "any"), |
| 525 | + "franchises_logic": criteria.get("franchises_logic", "any"), |
| 526 | + "collections_logic": criteria.get("collections_logic", "any"), |
| 527 | + "companies_logic": criteria.get("companies_logic", "any"), |
| 528 | + "age_ratings_logic": criteria.get("age_ratings_logic", "any"), |
| 529 | + "regions_logic": criteria.get("regions_logic", "any"), |
| 530 | + "languages_logic": criteria.get("languages_logic", "any"), |
| 531 | + "player_counts_logic": criteria.get("player_counts_logic", "any"), |
| 532 | + "statuses_logic": criteria.get("statuses_logic", "any"), |
| 533 | + "metadata_providers_logic": criteria.get("metadata_providers_logic", "any"), |
| 534 | + "tags_logic": criteria.get("tags_logic", "any"), |
| 535 | + } |
| 536 | + |
| 537 | + @begin_session |
| 538 | + def get_smart_collection_members( |
| 539 | + self, |
| 540 | + smart_collection: SmartCollection, |
| 541 | + user_id: int | None = None, |
| 542 | + session: Session = None, # type: ignore |
| 543 | + ) -> Sequence[Row[tuple[int, str | None, str | None]]]: |
| 544 | + """Every member's id and cover paths, in the collection's own order. |
| 545 | +
|
| 546 | + Only the columns the cached membership needs, so refreshing never |
| 547 | + hydrates ROM metadata. |
| 548 | + """ |
| 549 | + from handler.database import db_rom_handler |
| 550 | + |
| 551 | + criteria = smart_collection.filter_criteria |
| 552 | + query, _ = db_rom_handler.get_roms_query( |
543 | 553 | order_by=criteria.get("order_by", "name"), |
544 | 554 | order_dir=criteria.get("order_dir", "asc"), |
| 555 | + search_term=criteria.get("search_term"), |
| 556 | + user_id=user_id, |
| 557 | + session=session, |
545 | 558 | ) |
| 559 | + query = db_rom_handler.build_smart_collection_query( |
| 560 | + query=query, |
| 561 | + smart_collection=smart_collection, |
| 562 | + user_id=user_id, |
| 563 | + session=session, |
| 564 | + ).with_only_columns(Rom.id, Rom.path_cover_s, Rom.path_cover_l) |
| 565 | + |
| 566 | + return session.execute(query).all() |
| 567 | + |
| 568 | + @begin_session |
| 569 | + def refresh_smart_collection( |
| 570 | + self, |
| 571 | + id: int, |
| 572 | + session: Session = None, # type: ignore |
| 573 | + ) -> SmartCollection | None: |
| 574 | + """Recompute a smart collection's cached membership columns. |
| 575 | +
|
| 576 | + Those columns back the collections list and the ROM detail page, and are |
| 577 | + maintained on write: when the collection changes, and when the library |
| 578 | + does. They describe the owner's view, since the row is shared and |
| 579 | + criteria like `favorite` or `has_saves` answer differently per user. |
| 580 | + """ |
| 581 | + smart_collection = session.scalar( |
| 582 | + select(SmartCollection).filter_by(id=id).limit(1) |
| 583 | + ) |
| 584 | + if not smart_collection: |
| 585 | + return None |
| 586 | + |
| 587 | + members = self.get_smart_collection_members( |
| 588 | + smart_collection, user_id=smart_collection.user_id, session=session |
| 589 | + ) |
| 590 | + rom_ids = [member.id for member in members] |
| 591 | + covers_small = [ |
| 592 | + f"{FRONTEND_RESOURCES_PATH}/{member.path_cover_s}" |
| 593 | + for member in members |
| 594 | + if member.path_cover_s |
| 595 | + ][:SMART_COLLECTION_MAX_COVERS] |
| 596 | + covers_large = [ |
| 597 | + f"{FRONTEND_RESOURCES_PATH}/{member.path_cover_l}" |
| 598 | + for member in members |
| 599 | + if member.path_cover_l |
| 600 | + ][:SMART_COLLECTION_MAX_COVERS] |
| 601 | + |
| 602 | + # Compare without the cache-buster: it is read from `updated_at` before |
| 603 | + # the write bumps it, so a stored URL never carries the row's current |
| 604 | + # timestamp and comparing whole URLs would rewrite on every refresh. |
| 605 | + if ( |
| 606 | + smart_collection.rom_ids == rom_ids |
| 607 | + and _strip_cache_buster(smart_collection.path_covers_small) == covers_small |
| 608 | + and _strip_cache_buster(smart_collection.path_covers_large) == covers_large |
| 609 | + ): |
| 610 | + return smart_collection |
| 611 | + |
| 612 | + timestamp = smart_collection.updated_at |
| 613 | + return self.update_smart_collection( |
| 614 | + id, |
| 615 | + { |
| 616 | + "rom_count": len(rom_ids), |
| 617 | + "rom_ids": rom_ids, |
| 618 | + "path_covers_small": [f"{c}?ts={timestamp}" for c in covers_small], |
| 619 | + "path_covers_large": [f"{c}?ts={timestamp}" for c in covers_large], |
| 620 | + }, |
| 621 | + session=session, |
| 622 | + ) |
| 623 | + |
| 624 | + @begin_session |
| 625 | + def refresh_smart_collections( |
| 626 | + self, |
| 627 | + session: Session = None, # type: ignore |
| 628 | + ) -> int: |
| 629 | + """Refresh every smart collection, e.g. once the library has changed.""" |
| 630 | + ids = session.scalars(select(SmartCollection.id)).all() |
| 631 | + for id in ids: |
| 632 | + self.refresh_smart_collection(id, session=session) |
| 633 | + |
| 634 | + return len(ids) |
0 commit comments