library.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. import logging
  2. from mopidy.backend import LibraryProvider
  3. from mopidy.models import Ref
  4. logger = logging.getLogger(__name__)
  5. class SmartLibrary(LibraryProvider):
  6. def __init__(self, backend, config):
  7. super().__init__(backend)
  8. self.config = config
  9. self.max_tracks = int(config.get("max_tracks") or 50)
  10. raw_genres = config.get("genres") or ""
  11. self.fixed_genres = [g.strip() for g in raw_genres.split(",") if g.strip()]
  12. self.case_insensitive = bool(config.get("case_insensitive"))
  13. # Browsing: return Ref objects for directories/tracks
  14. def browse(self, uri):
  15. if uri in (None, "smart:", "smart://"):
  16. # top-level
  17. return [Ref.directory(uri="smart:genres", name="Genres")]
  18. if uri == "smart:genres":
  19. return [
  20. Ref.directory(uri=f"smart:genre:{g}", name=g.title())
  21. for g in self.fixed_genres
  22. ]
  23. if uri.startswith("smart:genre:"):
  24. genre = uri.split(":", 2)[2]
  25. tracks = self._tracks_for_genre(genre)
  26. return [Ref.track(uri=t.uri, name=t.name) for t in tracks]
  27. return []
  28. # Lookup: return full Track models for playback
  29. def lookup(self, uri):
  30. if uri.startswith("smart:genre:"):
  31. genre = uri.split(":", 2)[2]
  32. return self._tracks_for_genre(genre)
  33. return []
  34. # Internal: perform the search via Mopidy core/library API
  35. def _search_core(self, query):
  36. """Return list-like of SearchResult objects or [] if unavailable."""
  37. # Preferred: self.backend.core (available when Mopidy injects core)
  38. try:
  39. core = getattr(self.backend, "core", None)
  40. if core is not None:
  41. return core.library.search(query)
  42. except Exception:
  43. logger.debug("backend.core.library.search failed, falling back to actor RPC", exc_info=True)
  44. # Fallback: actor RPC (synchronous)
  45. try:
  46. actor = getattr(self.backend, "actor", None)
  47. if actor is not None and hasattr(actor, "root"):
  48. return actor.root.actor.library.search(query)
  49. except Exception:
  50. logger.exception("actor RPC search failed")
  51. return []
  52. def _tracks_for_genre(self, genre):
  53. qval = genre
  54. if self.case_insensitive:
  55. # Some backends store lowercase genres; do exact matching here but allow fallback
  56. qval = genre.lower()
  57. # Primary query: genre exact
  58. query = {"genre": [genre]}
  59. tracks = []
  60. try:
  61. results = self._search_core(query)
  62. for res in results:
  63. # res may be a SearchResult-like object (has .tracks)
  64. if hasattr(res, "tracks"):
  65. for t in res.tracks:
  66. tracks.append(t)
  67. if len(tracks) >= self.max_tracks:
  68. break
  69. if len(tracks) >= self.max_tracks:
  70. break
  71. # If case_insensitive and not enough results, try lowercase variant
  72. if self.case_insensitive and len(tracks) < self.max_tracks:
  73. query2 = {"genre": [qval]}
  74. if query2 != query:
  75. results2 = self._search_core(query2)
  76. for res in results2:
  77. if hasattr(res, "tracks"):
  78. for t in res.tracks:
  79. if t not in tracks:
  80. tracks.append(t)
  81. if len(tracks) >= self.max_tracks:
  82. break
  83. if len(tracks) >= self.max_tracks:
  84. break
  85. except Exception:
  86. logger.exception("Failed to search core for genre %s", genre)
  87. return tracks[: self.max_tracks]